Explorar o código

Feature/UI/combobox (#3409)

* Remove invalid markup from index.html

* Allow all tests to run

* Accessibility improvements

* Tidy up combobox hiding logic

* Update accessibility tests

* Revert to use short syntax

* Reduce visual noise in PR

* First review

* Remove superflous $data calls to improve performance

* Revert $refs syntax

* Match tailwind logic for active optiton while typing

* add unit tests

* Update index file

* Finishing touches on combobox

* Fix combobox accessibility

* Preserve selected state when opening box

* wip

* make sure we select the first match in the DOM for multiselect with more then one option selected

* Preserve active key for combobox only if activated by the user using the navigation keys

* Add test

---------

Co-authored-by: Caleb Porzio <calebporzio@gmail.com>
Simone Todaro hai 1 ano
pai
achega
9b969f633b

+ 100 - 320
index.html

@@ -9,291 +9,6 @@
 <script src="./packages/alpinejs/dist/cdn.js" defer></script>
 <script src="//cdn.tailwindcss.com"></script>
 
-<div class="px-5 py-16 min-h-[20rem] flex items-center justify-center">
-                                                    <div x-data="{
-        query: '',
-        selected: null,
-        frameworks: [
-            {
-                id: 1,
-                name: 'Laravel',
-                disabled: false,
-            },
-            {
-                id: 2,
-                name: 'Ruby on Rails',
-                disabled: false,
-            },
-            {
-                id: 3,
-                name: 'Django',
-                disabled: false,
-            },
-            {
-                id: 4,
-                name: 'Express',
-                disabled: false,
-            },
-            {
-                id: 5,
-                name: 'Phoenix',
-                disabled: false,
-            },
-            {
-                id: 6,
-                name: 'Adonis',
-                disabled: false,
-            },
-            {
-                id: 7,
-                name: 'NextJS',
-                disabled: false,
-            },
-        ],
-        get filteredFrameworks() {
-            return this.query === ''
-                ? this.frameworks
-                : this.frameworks.filter((framework) => {
-                    return framework.name.toLowerCase().includes(this.query.toLowerCase())
-                })
-        },
-        init() {
-            this.$watch('selected', (value) => {
-                console.log('watcher', value)
-                if (value) this.query = ''
-            })
-        },
-    }">
-    <div x-combobox="" x-model="selected">
-        <label x-combobox:label="" class="block text-sm text-gray-600" id="alpine-combobox-label-1">
-            Select framework
-        </label>
-
-        <div class="mt-1 relative">
-            <div class="flex items-center justify-between gap-2 w-64 bg-white pl-5 pr-3 py-2.5 rounded-md shadow">
-                <input x-combobox:input="" :display-value="framework => framework.name" @change="query = $event.target.value" class="border-none p-0 focus:outline-none focus:ring-0" placeholder="Search..." id="alpine-combobox-input-1" role="combobox" tabindex="0" aria-expanded="false" aria-labelledby="alpine-combobox-label-1" aria-controls="alpine-combobox-options-1">
-                <button x-combobox:button="" class="absolute inset-y-0 right-0 flex items-center pr-2" id="alpine-combobox-button-1" aria-haspopup="true" aria-labelledby="alpine-combobox-label-1 alpine-combobox-button-1" aria-expanded="false" tabindex="-1" type="button" aria-controls="alpine-combobox-options-1">
-                    <!-- Heroicons up/down -->
-                    <svg class="shrink-0 w-5 h-5 text-gray-500" viewBox="0 0 20 20" fill="none" stroke="currentColor"><path d="M7 7l3-3 3 3m0 6l-3 3-3-3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>
-                </button>
-            </div>
-
-            <div x-combobox:options="" class="absolute right-0 w-64 max-h-60 mt-2 z-10 origin-top-right overflow-hidden bg-white border border-gray-200 rounded-md shadow-md outline-none" x-transition="" id="alpine-combobox-options-1" role="combobox" aria-labelledby="alpine-combobox-label-1" style="display: none;">
-                <ul class="divide-y divide-gray-100">
-                    <template x-for="framework in filteredFrameworks" :key="framework.id" hidden="">
-                        <li x-combobox:option="" :value="framework" :disabled="framework.disabled" :class="{
-                                'bg-cyan-500/10 text-gray-900': $comboboxOption.isActive,
-                                'text-gray-600': ! $comboboxOption.isActive,
-                                'opacity-50 cursor-not-allowed': $comboboxOption.isDisabled,
-                            }" class="flex items-center cursor-default justify-between gap-2 w-full px-4 py-2 text-sm transition-colors">
-                            <span x-text="framework.name"></span>
-
-                            <span x-show="$comboboxOption.isSelected" class="text-cyan-600 font-bold">✓</span>
-                        </li>
-                    </template><li x-combobox:option="" :value="framework" :disabled="framework.disabled" :class="{
-                                'bg-cyan-500/10 text-gray-900': $comboboxOption.isActive,
-                                'text-gray-600': ! $comboboxOption.isActive,
-                                'opacity-50 cursor-not-allowed': $comboboxOption.isDisabled,
-                            }" class="flex items-center cursor-default justify-between gap-2 w-full px-4 py-2 text-sm transition-colors text-gray-600" id="alpine-combobox-option-9" role="option" tabindex="-1" aria-selected="true">
-                            <span x-text="framework.name">Django</span>
-
-                            <span x-show="$comboboxOption.isSelected" class="text-cyan-600 font-bold" style="">✓</span>
-                        </li>
-                </ul>
-
-                <p x-show="filteredFrameworks.length == 0" class="px-4 py-2 text-sm text-gray-600" style="display: none;">No frameworks match your query.</p>
-            </div>
-        </div>
-        <div>local selected: <span x-text="selected?.name">Ruby on Rails</span></div>
-        <div>internal selected: <span x-text="$combobox.value?.name">Django</span></div>
-    </div>
-</div>
-                                            </div>
-<!-- <div
-            x-data="{ active: null, people: [
-                { id: 1, name: 'Wade Cooper' },
-                { id: 2, name: 'Arlene Mccoy' },
-                { id: 3, name: 'Devon Webb', disabled: true },
-                { id: 4, name: 'Tom Cook' },
-                { id: 5, name: 'Tanya Fox', disabled: true },
-                { id: 6, name: 'Hellen Schmidt' },
-                { id: 7, name: 'Caroline Schultz' },
-                { id: 8, name: 'Mason Heaney' },
-                { id: 9, name: 'Claudie Smitham' },
-                { id: 10, name: 'Emil Schaefer' },
-            ]}"
-            x-listbox
-            x-model="active"
-        >
-            <label x-listbox:label>Assigned to</label>
-
-            <button x-listbox:button x-text="active ? active.name : 'Select Person'"></button>
-
-            <ul x-listbox:options options>
-                <template x-for="person in people" :key="person.id">
-                    <li
-                        :option="person.id"
-                        x-listbox:option
-                        :value="person"
-                        :disabled="person.disabled"
-                        :class="{
-                            'selected': $listboxOption.isSelected,
-                            'active': $listboxOption.isActive,
-                        }"
-                    >
-                        <span x-text="person.name"></span>
-                        <span x-show="$listboxOption.isActive">*</span>
-                    </li>
-                </template>
-            </ul>
-        </div> -->
-
-<!-- <div
-            x-data="{ active: null, people: [
-                { id: 1, name: 'Wade Cooper' },
-                { id: 2, name: 'Arlene Mccoy' },
-                { id: 3, name: 'Devon Webb' },
-                { id: 4, name: 'Tom Cook' },
-                { id: 5, name: 'Tanya Fox', disabled: true },
-                { id: 6, name: 'Hellen Schmidt' },
-                { id: 7, name: 'Caroline Schultz' },
-                { id: 8, name: 'Mason Heaney' },
-                { id: 9, name: 'Claudie Smitham' },
-                { id: 10, name: 'Emil Schaefer' },
-            ]}"
-            x-combobox
-            x-model="active"
-        >
-            <label x-combobox:label>Assigned to</label>
-
-            <input x-combobox:input :display-value="person => person.name" x-text="active ? active.name : 'Select Person'" type="text">
-            <button x-combobox:button x-text="active ? active.name : 'Select Person'"></button>
-
-            <ul x-combobox:options>
-                <template x-for="person in people" :key="person.id">
-                    <li
-                        :option="person.id"
-                        x-combobox:option
-                        :value="person"
-                        :disabled="person.disabled"
-                    >
-                        <span x-text="person.name"></span>
-                    </li>
-                </template>
-            </ul>
-        </div> -->
-
-<!-- <div
-            x-data="{
-                selected: null,
-                query: '',
-                people: [
-                    { id: 1, name: 'Wade Cooper' },
-                    { id: 2, name: 'Arlene Mccoy' },
-                    { id: 3, name: 'Devon Webb' },
-                    { id: 4, name: 'Tom Cook' },
-                    { id: 5, name: 'Tanya Fox', disabled: true },
-                    { id: 6, name: 'Hellen Schmidt' },
-                    { id: 7, name: 'Caroline Schultz' },
-                    { id: 8, name: 'Mason Heaney' },
-                    { id: 9, name: 'Claudie Smitham' },
-                    { id: 10, name: 'Emil Schaefer' },
-                ],
-                get filteredPeople() {
-                    return this.query.value === ''
-                        ? this.people
-                        : this.people.filter((person) => {
-                            return person.name.toLowerCase().includes(this.query.toLowerCase())
-                        })
-                }
-            }"
-            x-combobox
-            x-model="selected"
-            nullable
-        >
-            <span x-text="$combobox.active && $combobox.active.name"></span>
-            <label x-combobox:label>Assigned to</label>
-
-            <div>
-                <input x-combobox:input @change="query = $event.target.value" :display-value="() => person => person ? person.name : ''" type="text">
-                <button x-combobox:button x-text="selected ? selected.name : 'Select Person'">Select Person</button>
-            </div>
-
-            <ul x-combobox:options>
-                <template x-for="person in filteredPeople" :key="person.id">
-                    <li
-                        x-combobox:option
-                        :value="person"
-                        :disabled="person.disabled"
-                        :class="$comboboxOption.isActive && 'foo'"
-                        >
-                        <span x-show="$comboboxOption.isSelected">-</span>
-                        <span x-text="person.name"></span>
-                        <span x-show="$comboboxOption.isActive">*</span>
-                    </li>
-                </template>
-            </ul>
-        </div> -->
-
-<!-- Multiple -->
-<!-- <div
-            x-data="{
-                selected: [],
-                query: '',
-                people: [
-                    { id: 1, name: 'Wade Cooper' },
-                    { id: 2, name: 'Arlene Mccoy' },
-                    { id: 3, name: 'Devon Webb' },
-                    { id: 4, name: 'Tom Cook' },
-                    { id: 5, name: 'Tanya Fox', disabled: true },
-                    { id: 6, name: 'Hellen Schmidt' },
-                    { id: 7, name: 'Caroline Schultz' },
-                    { id: 8, name: 'Mason Heaney' },
-                    { id: 9, name: 'Claudie Smitham' },
-                    { id: 10, name: 'Emil Schaefer' },
-                ],
-                get filteredPeople() {
-                    return this.query.value === ''
-                        ? this.people
-                        : this.people.filter((person) => {
-                            return person.name.toLowerCase().includes(this.query.toLowerCase())
-                        })
-                }
-            }"
-            x-combobox
-            multiple
-            by="id"
-            x-model="selected"
-        >
-            <span x-text="$combobox.active && $combobox.active.name"></span>
-            <label x-combobox:label>Assigned to</label>
-
-            <div>
-                <div x-effect="selected.forEach(i => console.log(JSON.stringify(i)))">
-                    <template x-for="selectedPerson in selected" :key="selectedPerson.id">
-                        <button x-text="selectedPerson.name" @click="selected = selected.filter(i => i.id !== selectedPerson.id)"></button>
-                    </template>
-                </div>
-                <input x-combobox:input @change="query = $event.target.value" :display-value="() => person => person.map(i => i.name).join(', ')" type="text">
-                <button x-combobox:button x-text="selected.length ? selected.map(i => i.name).join(', ') : 'Selected Person'">Select Person</button>
-            </div>
-
-            <ul x-combobox:options>
-                <template x-for="person in filteredPeople" :key="person.id">
-                    <li
-                        x-combobox:option
-                        :value="person"
-                        :disabled="person.disabled"
-                        :class="$comboboxOption.isActive && 'foo'"
-                    >
-                        <span x-show="$comboboxOption.isSelected">-</span>
-                        <span x-text="person.name"></span>
-                        <span x-show="$comboboxOption.isActive">*</span>
-                    </li>
-                </template>
-            </ul>
-        </div> -->
-
 <div
     x-data="{
         query: '',
@@ -401,48 +116,113 @@
     </div>
 </div>
 
-<!-- <div x-data="{ active: true }">
-            <button @click="active = ! active">switch active</button>
-            <div
-                x-foo
-                :doo="active"
-                :class="$foo.isActive ? 'active' : 'not active'"
-            >
-                <div x-text="$foo.isActive ? 'active' : 'not active'"></div>
-            </div>
-        </div> -->
-
-<script>
+<div
+    x-data="{
+        query: '',
+        selected: null,
+        frameworks: [
+            {
+                id: 1,
+                name: 'Laravel',
+                disabled: false,
+            },
+            {
+                id: 2,
+                name: 'Ruby on Rails',
+                disabled: false,
+            },
+            {
+                id: 3,
+                name: 'Django',
+                disabled: false,
+            },
+            {
+                id: 4,
+                name: 'Express',
+                disabled: false,
+            },
+            {
+                id: 5,
+                name: 'Phoenix',
+                disabled: false,
+            },
+            {
+                id: 6,
+                name: 'Adonis',
+                disabled: false,
+            },
+            {
+                id: 7,
+                name: 'NextJS',
+                disabled: false,
+            },
+        ],
+        get filteredFrameworks() {
+            return this.query === ''
+                ? this.frameworks
+                : this.frameworks.filter((framework) => {
+                    return framework.name.toLowerCase().includes(this.query.toLowerCase())
+                })
+        }
+    }"
 
-            // document.addEventListener('alpine:init', () => {
-            //     let directive = el => {
-            //         el.__isActive = Alpine.reactive({ state: false })
+    class="flex h-full w-screen justify-center bg-gray-50 p-12"
+>
+    <div x-combobox x-model="selected">
+        <label x-combobox:label class="block text-sm text-gray-600">
+            Select framework
+        </label>
 
-            //         Alpine.effect(() => {
-            //             el.__isActive.state = Alpine.bound(el, 'doo');
-            //         })
+        <div class="mt-1 relative">
+            <div class="flex items-center justify-between gap-2 w-64 bg-white pl-5 pr-3 py-2.5 rounded-md shadow">
+                <input
+                    x-combobox:input
+                    :display-value="framework => framework.name"
+                    @change="query = $event.target.value"
+                    class="border-none p-0 focus:outline-none focus:ring-0"
+                    placeholder="Search..."
+                />
+                <button x-combobox:button class="absolute inset-y-0 right-0 flex items-center pr-2">
+                    <!-- Heroicons up/down -->
+                    <svg class="shrink-0 w-5 h-5 text-gray-500" viewBox="0 0 20 20" fill="none" stroke="currentColor"><path d="M7 7l3-3 3 3m0 6l-3 3-3-3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>
+                </button>
+            </div>
 
-            //         el.__isOption.ready = true
-            //     }
+            <div x-combobox:options x-cloak class="absolute right-0 w-64 max-h-60 mt-2 z-10 origin-top-right overflow-hidden bg-white border border-gray-200 rounded-md shadow-md outline-none" x-transition>
+                <ul class="divide-y divide-gray-100">
+                    <template
+                        x-for="framework in filteredFrameworks"
+                        :key="framework.id"
+                        hidden
+                    >
+                        <li
+                            x-combobox:option
+                            :value="framework"
+                            :disabled="framework.disabled"
+                            :class="{
+                                'bg-cyan-500/10 text-gray-900': $comboboxOption.isActive,
+                                'text-gray-600': ! $comboboxOption.isActive,
+                                'opacity-50 cursor-not-allowed': $comboboxOption.isDisabled,
+                            }"
+                            class="flex items-center cursor-default justify-between gap-2 w-full px-4 py-2 text-sm"
+                        >
+                            <span x-text="framework.name"></span>
 
-            //     directive.inline = el => {
-            //         el.__isOption = Alpine.reactive({ ready: false })
-            //     }
+                            <span x-show="$comboboxOption.isSelected" class="text-cyan-600 font-bold">&check;</span>
+                        </li>
+                    </template>
+                </ul>
 
-            //     Alpine.directive('foo', directive)
+                <p x-show="filteredFrameworks.length == 0" class="px-4 py-2 text-sm text-gray-600">No frameworks match your query.</p>
+            </div>
+        </div>
+        <div>local selected: <span x-text="selected?.name"></span></div>
+        <div>internal selected: <span x-text="$combobox.value?.name"></span></div>
+            <article x-text="$combobox.activeIndex"></article>
+    </div>
+</div>
 
-            //     Alpine.magic('foo', el => {
-            //         if (el.__isOption && ! el.__isOption.ready) {
-            //             return { isActive: true }
-            //         }
 
-            //         let optionEl = Alpine.findClosest(el, el => el.__isOption)
 
-            //         return {
-            //             isActive: optionEl.__isActive.state,
-            //         }
-            //     })
-            // })
-</script>
 
 </html>

+ 84 - 81
packages/ui/src/combobox.js

@@ -85,24 +85,6 @@ function handleRoot(el, Alpine) {
                 __inputName: null,
                 __isTyping: false,
                 __hold: false,
-                __pointer: {
-                    lastPosition: [-1, -1],
-
-                    wasMoved(e) {
-                        let newPosition = [e.screenX, e.screenY]
-
-                        if (this.lastPosition[0] === newPosition[0] && this.lastPosition[1] === newPosition[1]) {
-                            return false
-                        }
-
-                        this.lastPosition = newPosition
-                        return true
-                    },
-
-                    update(e) {
-                        this.lastPosition = [e.screenX, e.screenY]
-                    },
-                },
 
                 /**
                  * Combobox initialization...
@@ -114,7 +96,7 @@ function handleRoot(el, Alpine) {
                     this.__nullable = Alpine.extractProp(el, 'nullable', false)
                     this.__compareBy = Alpine.extractProp(el, 'by')
 
-                    this.__context = generateContext(this.__isMultiple, 'vertical', () => this.$data.__activateSelectedOrFirst())
+                    this.__context = generateContext(this.__isMultiple, 'vertical', () => this.__activateSelectedOrFirst())
 
                     let defaultValue = Alpine.extractProp(el, 'default-value', this.__isMultiple ? [] : null)
 
@@ -134,24 +116,22 @@ function handleRoot(el, Alpine) {
                 __startTyping() {
                     this.__isTyping = true
                 },
-                __stopTyping(resetInput = true) {
+                __stopTyping() {
                     this.__isTyping = false
-
-                    if (resetInput) this.$data.__resetInput()
                 },
                 __resetInput() {
                     let input = this.$refs.__input
+
                     if (! input) return
 
-                    let value = this.$data.__getCurrentValue()
+                    let value = this.__getCurrentValue()
 
                     input.value = value
-                    input.dispatchEvent(new Event('change'))
                 },
                 __getCurrentValue() {
                     if (! this.$refs.__input) return ''
                     if (! this.__value) return ''
-                    if (this.$data.__displayValue && this.__value !== undefined) return this.$data.__displayValue(this.__value)
+                    if (this.__displayValue) return this.__displayValue(this.__value)
                     if (typeof this.__value === 'string') return this.__value
                     return ''
                 },
@@ -159,13 +139,36 @@ function handleRoot(el, Alpine) {
                     if (this.__isOpen) return
                     this.__isOpen = true
 
-                    this.__activateSelectedOrFirst()
+                    let input = this.$refs.__input
+
+                    // Make sure we always notify the parent component
+                    // that the starting value is the empty string
+                    // when we open the combobox (ignoring any existing value)
+                    // to avoid inconsistent displaying.
+                    // Setting the input to empty and back to the real value
+                    // also helps VoiceOver to annunce the content properly
+                    // See https://github.com/tailwindlabs/headlessui/pull/2153
+                    if (input) {
+                        let value = input.value
+                        let { selectionStart, selectionEnd, selectionDirection } = input
+                        input.value = ''
+                        input.dispatchEvent(new Event('change'))
+                        input.value = value
+                        if (selectionDirection !== null) {
+                            input.setSelectionRange(selectionStart, selectionEnd, selectionDirection)
+                        } else {
+                            input.setSelectionRange(selectionStart, selectionEnd)
+                        }
+                    }
 
                     // Safari needs more of a "tick" for focusing after x-show for some reason.
                     // Probably because Alpine adds an extra tick when x-showing for @click.outside
                     let nextTick = callback => requestAnimationFrame(() => requestAnimationFrame(callback))
 
-                    nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
+                    nextTick(() => {
+                        this.$refs.__input.focus({ preventScroll: true })
+                        this.__activateSelectedOrFirst()
+                    })
                 },
                 __close() {
                     this.__isOpen = false
@@ -175,31 +178,32 @@ function handleRoot(el, Alpine) {
                 __activateSelectedOrFirst(activateSelected = true) {
                     if (! this.__isOpen) return
 
-                    if (this.__context.activeKey) {
-                        this.__context.activateAndScrollToKey(this.__context.activeKey)
-                        return
-                    }
+                    if (this.__context.hasActive() && this.__context.wasActivatedByKeyPress()) return
 
                     let firstSelectedValue
 
                     if (this.__isMultiple) {
-                        firstSelectedValue = this.__value.find(i => {
-                            return !! this.__context.getItemByValue(i)
-                        })
+                        let selectedItem = this.__context.getItemsByValues(this.__value)
+
+                        firstSelectedValue = selectedItem.length ? selectedItem[0].value : null
                     } else {
                         firstSelectedValue = this.__value
                     }
 
+                    let firstSelected = null
                     if (activateSelected && firstSelectedValue) {
-                        let firstSelected = this.__context.getItemByValue(firstSelectedValue)
+                        firstSelected = this.__context.getItemByValue(firstSelectedValue)
+                    }
 
-                        firstSelected && this.__context.activateAndScrollToKey(firstSelected.key)
-                    } else {
-                        this.__context.activateAndScrollToKey(this.__context.firstKey())
+                    if (firstSelected) {
+                        this.__context.activateAndScrollToKey(firstSelected.key)
+                        return
                     }
+
+                    this.__context.activateAndScrollToKey(this.__context.firstKey())
                 },
                 __selectActive() {
-                    let active = this.$data.__context.getActiveItem()
+                    let active = this.__context.getActiveItem()
                     if (active) this.__toggleSelected(active.value)
                 },
                 __selectOption(el) {
@@ -249,7 +253,6 @@ function handleRoot(el, Alpine) {
                 },
             }
         },
-
         // Register event listeners..
         '@mousedown.window'(e) {
             if (
@@ -257,8 +260,8 @@ function handleRoot(el, Alpine) {
                 && ! this.$refs.__button.contains(e.target)
                 && ! this.$refs.__options.contains(e.target)
             ) {
-                this.$data.__resetInput()
-                this.$data.__close()
+                this.__close()
+                this.__resetInput()
             }
         }
     })
@@ -273,6 +276,8 @@ function handleInput(el, Alpine) {
         // Accessibility attributes...
         'role': 'combobox',
         'tabindex': '0',
+        'aria-autocomplete': 'list',
+
         // We need to defer this evaluation a bit because $refs that get declared later
         // in the DOM aren't available yet when x-ref is the result of an Alpine.bind object.
         async ':aria-controls'() { return await microtask(() => this.$refs.__options && this.$refs.__options.id) },
@@ -295,16 +300,19 @@ function handleInput(el, Alpine) {
 
         // Register listeners...
         '@input.stop'(e) {
-            this.$data.__open(); this.$dispatch('change')
+            if(this.$data.__isTyping) {
+                this.$data.__open();
+                this.$dispatch('change')
+            }
         },
         '@blur'() { this.$data.__stopTyping(false) },
         '@keydown'(e) {
             queueMicrotask(() => this.$data.__context.activateByKeyEvent(e, false, () => this.$data.__isOpen, () => this.$data.__open(), (state) => this.$data.__isTyping = state))
-         },
+        },
         '@keydown.enter.prevent.stop'() {
             this.$data.__selectActive()
 
-            this.$data.isTyping = false
+            this.$data.__stopTyping()
 
             if (! this.$data.__isMultiple) {
                 this.$data.__close()
@@ -314,15 +322,15 @@ function handleInput(el, Alpine) {
         '@keydown.escape.prevent'(e) {
             if (! this.$data.__static) e.stopPropagation()
 
+            this.$data.__stopTyping()
             this.$data.__close()
+            this.$data.__resetInput()
 
-            this.$data.__stopTyping()
         },
         '@keydown.tab'() {
             this.$data.__stopTyping()
-            this.$data.__resetInput()
-
             if (this.$data.__isOpen) { this.$data.__close() }
+            this.$data.__resetInput()
         },
         '@keydown.backspace'(e) {
             if (this.$data.__isMultiple) return
@@ -369,8 +377,8 @@ function handleButton(el, Alpine) {
         '@click'(e) {
             if (this.$data.__isDisabled) return
             if (this.$data.__isOpen) {
-                this.$data.__resetInput()
                 this.$data.__close()
+                this.$data.__resetInput()
             } else {
                 e.preventDefault()
                 this.$data.__open()
@@ -396,15 +404,8 @@ function handleOptions(el, Alpine) {
         ':id'() { return this.$id('alpine-combobox-options') },
 
         // Accessibility attributes...
-        'role': 'combobox',
+        'role': 'listbox',
         ':aria-labelledby'() { return this.$refs.__label ? this.$refs.__label.id : (this.$refs.__button ? this.$refs.__button.id : null) },
-        ':aria-activedescendant'() {
-            if (! this.$data.__context.hasActive()) return
-
-            let active = this.$data.__context.getActiveItem()
-
-            return active ? active.el.id : null
-        },
 
         // Initialize...
         'x-init'() {
@@ -428,28 +429,32 @@ function handleOption(el, Alpine) {
         // Accessibility attributes...
         'role': 'option',
         ':tabindex'() { return this.$comboboxOption.isDisabled ? undefined : '-1' },
-        ':aria-selected'() { return this.$comboboxOption.isSelected },
+
+        // Only the active element should have aria-selected="true"...
+        'x-effect'() {
+            this.$comboboxOption.isActive
+                ? el.setAttribute('aria-selected', true)
+                : el.removeAttribute('aria-selected')
+        },
+
         ':aria-disabled'() { return this.$comboboxOption.isDisabled },
 
         // Initialize...
         'x-data'() {
             return {
                 init() {
-                    let key = el.__optionKey = (Math.random() + 1).toString(36).substring(7)
-
-                    let value = Alpine.extractProp(el, 'value')
-                    let disabled = Alpine.extractProp(el, 'disabled', false, false)
+                    let key = this.$el.__optionKey = (Math.random() + 1).toString(36).substring(7)
 
-                    this.$data.__context.registerItem(key, el, value, disabled)
+                    let value = Alpine.extractProp(this.$el, 'value')
+                    let disabled = Alpine.extractProp(this.$el, 'disabled', false, false)
 
-                    // @todo: make sure the "destroy" hook is good enough and we don't need this...
-                    // el._x_forCleanup = () => {
-                        // this.$data.__context.unregisterItem(key)
-                    // }
+                    // memoize the context as it's not going to change
+                    // and calling this.$data on mouse action is expensive
+                    this.__context.registerItem(key, this.$el, value, disabled)
                 },
                 destroy() {
-                    this.$data.__context.unregisterItem(this.$el.__optionKey)
-                },
+                    this.__context.unregisterItem(this.$el.__optionKey)
+                }
             }
         },
 
@@ -457,34 +462,32 @@ function handleOption(el, Alpine) {
         '@click'() {
             if (this.$comboboxOption.isDisabled) return;
 
-            this.$data.__selectOption(el)
+            this.__selectOption(this.$el)
 
-            if (! this.$data.__isMultiple) {
-                this.$data.__resetInput()
-                this.$data.__close()
+            if (! this.__isMultiple) {
+                this.__close()
+                this.__resetInput()
             }
 
             this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
         },
-
-        // @todo: this is a memory leak for _x_cleanups...
         '@mouseenter'(e) {
-            this.$data.__pointer.update(e)
+            this.__context.activateEl(this.$el)
         },
         '@mousemove'(e) {
-            if (!this.$data.__pointer.wasMoved(e)) return
+            if (this.__context.isActiveEl(this.$el)) return
 
-            this.$data.__context.activateEl(el)
+            this.__context.activateEl(this.$el)
         },
         '@mouseleave'(e) {
-            if (!this.$data.__pointer.wasMoved(e)) return
-            if (this.$data.__hold) return
+            if (this.__hold) return
 
-            this.$data.__context.deactivate()
+            this.__context.deactivate()
         },
     })
 }
 
+
 // Little utility to defer a callback into the microtask queue...
 function microtask(callback) {
     return new Promise(resolve => queueMicrotask(() => resolve(callback())))

+ 36 - 138
packages/ui/src/list-context.js

@@ -5,13 +5,9 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst)
          * Main state...
          */
         items: [],
-
-        disabledKeys: [],
         activeKey: null,
-        selectedKeys: [],
         orderedKeys: [],
-        elsByKey: {},
-        values: {},
+        activatedByKeyPress: false,
 
         /**
          *  Initialization...
@@ -54,12 +50,24 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst)
             return this.items.find(i => i.el === el)
         },
 
+        getItemsByValues(values) {
+            let rawValues = values.map(i => Alpine.raw(i));
+            let filteredValue = this.items.filter(i => rawValues.includes(Alpine.raw(i.value)))
+            filteredValue = filteredValue.slice().sort((a, b) => {
+                let position = a.el.compareDocumentPosition(b.el)
+                if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
+                if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
+                return 0
+            })
+            return filteredValue
+        },
+
         getActiveItem() {
             if (! this.hasActive()) return null
 
             let item = this.items.find(i => i.key === this.activeKey)
 
-            if (! item) return this.activeKey = null
+            if (! item) this.deactivateKey(this.activeKey)
 
             return item
         },
@@ -67,7 +75,7 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst)
         activateItem(item) {
             if (! item) return
 
-            this.activeKey = item.key
+            this.activateKey(item.key)
         },
 
         /**
@@ -91,7 +99,7 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst)
 
             // If there no longer is the active key in the items list, then
             // deactivate it...
-            if (! this.orderedKeys.includes(this.activeKey)) this.activeKey = null
+            if (! this.orderedKeys.includes(this.activeKey)) this.deactivateKey(this.activeKey)
         }),
 
         activeEl() {
@@ -120,7 +128,7 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst)
 
         scrollingCount: 0,
 
-        activateAndScrollToKey(key) {
+        activateAndScrollToKey(key, activatedByKeyPress) {
             if (! this.getItemByKey(key)) return
 
             // This addresses the following problem:
@@ -129,7 +137,7 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst)
             // This "isScrollingTo" is exposed to prevent that.
             this.scrollingCount++
 
-            this.activateKey(key)
+            this.activateKey(key, activatedByKeyPress)
 
             let targetEl = this.items.find(i => i.key === key).el
 
@@ -143,57 +151,6 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst)
             }, 25)
         },
 
-        /**
-         * Handle values...
-         */
-        // selectedValueOrValues() {
-        //     if (multiple) {
-        //         return this.selectedValues()
-        //     } else {
-        //         return this.selectedValue()
-        //     }
-        // },
-
-        // selectedValues() {
-        //     return this.selectedKeys.map(i => this.values[i])
-        // },
-
-        // selectedValue() {
-        //     return this.selectedKeys[0] ? this.values[this.selectedKeys[0]] : null
-        // },
-
-        // selectValue(value, by) {
-        //     if (!value) value = (multiple ? [] : null)
-        //     if (! by) by = (a, b) => a === b
-
-        //     if (typeof by === 'string') {
-        //         let property = by
-        //         by = (a, b) => a[property] === b[property]
-        //     }
-
-        //     if (multiple) {
-        //         let keys = []
-
-        //         value.forEach(i => {
-        //             for (let key in this.values) {
-        //                 if (by(this.values[key], i)) {
-        //                     if (! keys.includes(key)) {
-        //                         keys.push(key)
-        //                     }
-        //                 }
-        //             }
-        //         })
-
-        //         this.selectExclusive(keys)
-        //     } else {
-        //         for (let key in this.values) {
-        //             if (value && by(this.values[key], value)) {
-        //                 this.selectKey(key)
-        //             }
-        //         }
-        //     }
-        // },
-
         /**
          * Handle disabled keys...
          */
@@ -210,95 +167,32 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst)
         },
 
         /**
-         * Handle selected keys...
+         * Handle activated keys...
          */
-        // selectKey(key) {
-        //     if (this.isDisabled(key)) return
-
-        //     if (multiple) {
-        //         this.toggleSelected(key)
-        //     } else {
-        //         this.selectOnly(key)
-        //     }
-        // },
-
-        // toggleSelected(key) {
-        //     console.log(key)
-        //     if (this.selectedKeys.includes(key)) {
-        //         this.selectedKeys.splice(this.selectedKeys.indexOf(key), 1)
-        //     } else {
-        //         this.selectedKeys.push(key)
-        //     }
-        // },
-
-        // selectOnly(key) {
-        //     this.selectedKeys = []
-        //     this.selectedKeys.push(key)
-        // },
-
-        // selectExclusive(keys) {
-        //     // We can't just do this.selectedKeys = keys,
-        //     // because we need to preserve reactivity...
-
-        //     let toAdd = [...keys]
-
-        //     for (let i = 0; i < this.selectedKeys.length; i++) {
-        //         if (keys.includes(this.selectedKeys[i])) {
-        //             delete toAdd[toAdd.indexOf(this.selectedKeys[i])]
-        //             continue;
-        //         }
-
-        //         if (! keys.includes(this.selectedKeys[i])) {
-        //             this.selectedKeys.splice(i, 1)
-        //         }
-        //     }
-
-        //     toAdd.forEach(i => {
-        //         this.selectedKeys.push(i)
-        //     })
-        // },
-
-        // selectActive(key) {
-        //     if (! this.activeKey) return
-
-        //     this.selectKey(this.activeKey)
-        // },
-
-        // isSelected(key) { return this.selectedKeys.includes(key) },
-
-
-        // firstSelectedKey() { return this.selectedKeys[0] },
+        hasActive() { return !! this.activeKey },
 
         /**
-         * Handle activated keys...
+         * Return true if the latest active element was activated
+         * by the user (i.e. using the arrow keys) and false if was
+         * activated automatically by alpine (i.e. first element automatically
+         * activeted after filtering the list)
          */
-        hasActive() { return !! this.activeKey },
+        wasActivatedByKeyPress() {return this.activatedByKeyPress},
 
         isActiveKey(key) { return this.activeKey === key },
 
-
-        // activateSelectedOrFirst() {
-        //     let firstSelected = this.firstSelectedKey()
-
-        //     if (firstSelected) {
-        //         return this.activateKey(firstSelected)
-        //     }
-
-        //     let firstKey = this.firstKey()
-
-        //     if (firstKey) {
-        //         this.activateKey(firstKey)
-        //     }
-        // },
-
-        activateKey(key) {
+        activateKey(key, activatedByKeyPress = false) {
             if (this.isDisabled(key)) return
 
             this.activeKey = key
+            this.activatedByKeyPress = activatedByKeyPress
         },
 
         deactivateKey(key) {
-            if (this.activeKey === key) this.activeKey = null
+            if (this.activeKey === key) {
+                this.activeKey = null
+                this.activatedByKeyPress = false
+            }
         },
 
         deactivate() {
@@ -306,6 +200,7 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst)
             if (this.isScrollingTo) return
 
             this.activeKey = null
+            this.activatedByKeyPress = false
         },
 
         /**
@@ -361,6 +256,8 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst)
 
             setIsTyping(true)
 
+            let activatedByKeyPress = true
+
             switch (e.key) {
                 // case 'Backspace':
                 // case 'Delete':
@@ -410,6 +307,7 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst)
                     break;
 
                 default:
+                    activatedByKeyPress = this.activatedByKeyPress
                     if (searchable && e.key.length === 1) {
                         targetKey = this.searchKey(e.key)
                     }
@@ -417,7 +315,7 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst)
             }
 
             if (targetKey) {
-                this.activateAndScrollToKey(targetKey)
+                this.activateAndScrollToKey(targetKey, activatedByKeyPress)
             }
         }
     }

+ 509 - 13
tests/cypress/integration/plugins/ui/combobox.spec.js

@@ -1,14 +1,9 @@
-import { beVisible, beHidden, haveAttribute, haveClasses, notHaveClasses, haveText, contain, notContain, html, notBeVisible, notHaveAttribute, notExist, haveFocus, test, haveValue} from '../../../utils'
+import { beVisible, beHidden, haveAttribute, haveClasses, notHaveClasses, haveText, contain, notContain, html, notBeVisible, notHaveAttribute, notExist, haveFocus, test, haveValue, haveLength} from '../../../utils'
 
-test.only('it works with x-model',
+test('it works with x-model',
     [html`
         <div
             x-data="{
-                init() {
-                    this.$watch('selected', (value) => {
-                        if (value) this.query = ''
-                    })
-                },
                 query: '',
                 selected: null,
                 people: [
@@ -276,6 +271,180 @@ test('"name" prop',
     },
 );
 
+test('Preserves currenty active keyboard selection while options change from searching even if there\'s a selected option in the filtered results',
+    [html`
+        <div
+            x-data="{
+                query: '',
+                selected: null,
+                people: [
+                    { id: 1, name: 'Wade Cooper' },
+                    { id: 2, name: 'Arlene Mccoy' },
+                    { id: 3, name: 'Devon Webb' },
+                    { id: 4, name: 'Tom Cook' },
+                    { id: 5, name: 'Tanya Fox', disabled: true },
+                    { id: 6, name: 'Hellen Schmidt' },
+                    { id: 7, name: 'Caroline Schultz' },
+                    { id: 8, name: 'Mason Heaney' },
+                    { id: 9, name: 'Claudie Smitham' },
+                    { id: 10, name: 'Emil Schaefer' },
+                ],
+                get filteredPeople() {
+                    return this.query === ''
+                        ? this.people
+                        : this.people.filter((person) => {
+                            return person.name.toLowerCase().includes(this.query.toLowerCase())
+                        })
+                },
+            }"
+        >
+            <div x-combobox x-model="selected">
+                <label x-combobox:label>Select person</label>
+
+                <div>
+                    <div>
+                        <input
+                            x-combobox:input
+                            :display-value="person => person.name"
+                            @change="query = $event.target.value"
+                            placeholder="Search..."
+                        />
+
+                        <button x-combobox:button>Toggle</button>
+                    </div>
+
+                    <div x-combobox:options>
+                        <ul>
+                            <template
+                                x-for="person in filteredPeople"
+                                :key="person.id"
+                                hidden
+                            >
+                                <li
+                                    x-combobox:option
+                                    :option="person.id"
+                                    :value="person"
+                                    :disabled="person.disabled"
+                                >
+                                    <span x-text="person.name"></span>
+                                    <span x-show="$comboboxOption.isActive">*</span>
+                                    <span x-show="$comboboxOption.isSelected">x</span>
+                                </li>
+                            </template>
+                        </ul>
+
+                        <p x-show="filteredPeople.length == 0">No people match your query.</p>
+                    </div>
+                </div>
+            </div>
+
+            <article>lorem ipsum</article>
+        </div>
+    `],
+    ({ get }) => {
+        get('input').should(haveText(''))
+        get('button').click()
+        get('[option="3"]').click()
+        cy.wait(100)
+        get('input').type('{selectAll}{backspace}')
+        cy.wait(100)
+        get('input').type('{downArrow}')
+        cy.wait(100)
+        get('[option="3"]').should(contain('*'))
+        get('input').type('{upArrow}{upArrow}')
+        cy.wait(100)
+        get('[option="1"]').should(contain('*'))
+        cy.wait(100)
+        get('input').type('d')
+        get('input').trigger('change')
+        cy.wait(100)
+        get('[option="1"]').should(contain('*'))
+    },
+);
+
+test('Ignore active selection while options change if not selected by a keyboard event',
+    [html`
+        <div
+            x-data="{
+                query: '',
+                selected: null,
+                people: [
+                    { id: 1, name: 'Wade Cooper' },
+                    { id: 2, name: 'Arlene Mccoy' },
+                    { id: 3, name: 'Devon Webb' },
+                    { id: 4, name: 'Tom Cook' },
+                    { id: 5, name: 'Tanya Fox', disabled: true },
+                    { id: 6, name: 'Hellen Schmidt' },
+                    { id: 7, name: 'Caroline Schultz' },
+                    { id: 8, name: 'Mason Heaney' },
+                    { id: 9, name: 'Claudie Smitham' },
+                    { id: 10, name: 'Emil Schaefer' },
+                ],
+                get filteredPeople() {
+                    return this.query === ''
+                        ? this.people
+                        : this.people.filter((person) => {
+                            return person.name.toLowerCase().includes(this.query.toLowerCase())
+                        })
+                },
+            }"
+        >
+            <div x-combobox x-model="selected">
+                <label x-combobox:label>Select person</label>
+
+                <div>
+                    <div>
+                        <input
+                            x-combobox:input
+                            :display-value="person => person.name"
+                            @change="query = $event.target.value"
+                            placeholder="Search..."
+                        />
+
+                        <button x-combobox:button>Toggle</button>
+                    </div>
+
+                    <div x-combobox:options>
+                        <ul>
+                            <template
+                                x-for="person in filteredPeople"
+                                :key="person.id"
+                                hidden
+                            >
+                                <li
+                                    x-combobox:option
+                                    :option="person.id"
+                                    :value="person"
+                                    :disabled="person.disabled"
+                                >
+                                    <span x-text="person.name"></span>
+                                    <span x-show="$comboboxOption.isActive">*</span>
+                                    <span x-show="$comboboxOption.isSelected">x</span>
+                                </li>
+                            </template>
+                        </ul>
+
+                        <p x-show="filteredPeople.length == 0">No people match your query.</p>
+                    </div>
+                </div>
+            </div>
+
+            <article>lorem ipsum</article>
+        </div>
+    `],
+    ({ get }) => {
+        get('input').should(haveText(''))
+        get('button').click()
+        get('[option="1"]').should(contain('*'))
+        get('input').type('t')
+        get('input').trigger('change')
+        get('[option="4"]').should(contain('*'))
+        get('input').type('{backspace}')
+        get('input').trigger('change')
+        get('[option="1"]').should(contain('*'))
+    },
+);
+
 test('"name" prop with object value',
     [html`
         <div
@@ -444,6 +613,11 @@ test('"multiple" prop',
         get('[option="4"]').click()
         get('button').should(haveText('2'))
         get('ul').should(beVisible())
+        get('input').type('Tom')
+        get('input').type('{enter}')
+        get('button').should(haveText('2,4'))
+        // input field doesn't reset when a new option is selected
+        get('input').should(haveValue('Tom'))
     },
 );
 
@@ -716,41 +890,49 @@ test('has accessibility attributes',
         </div>
     `],
     ({ get }) => {
+        get('input')
+            .should(haveAttribute('aria-expanded', 'false'))
+
         get('button')
             .should(haveAttribute('aria-haspopup', 'true'))
             .should(haveAttribute('aria-labelledby', 'alpine-combobox-label-1 alpine-combobox-button-1'))
             .should(haveAttribute('aria-expanded', 'false'))
             .should(notHaveAttribute('aria-controls'))
             .should(haveAttribute('id', 'alpine-combobox-button-1'))
+            .should(haveAttribute('tabindex', '-1'))
             .click()
             .should(haveAttribute('aria-expanded', 'true'))
             .should(haveAttribute('aria-controls', 'alpine-combobox-options-1'))
 
         get('[options]')
-            .should(haveAttribute('role', 'combobox'))
+            .should(haveAttribute('role', 'listbox'))
             .should(haveAttribute('id', 'alpine-combobox-options-1'))
             .should(haveAttribute('aria-labelledby', 'alpine-combobox-label-1'))
-            .should(notHaveAttribute('aria-activedescendant'))
-            .should(haveAttribute('aria-activedescendant', 'alpine-combobox-option-1'))
 
         get('[option="1"]')
             .should(haveAttribute('role', 'option'))
             .should(haveAttribute('id', 'alpine-combobox-option-1'))
             .should(haveAttribute('tabindex', '-1'))
-            .should(haveAttribute('aria-selected', 'false'))
+            .should(haveAttribute('aria-selected', 'true'))
 
         get('[option="2"]')
             .should(haveAttribute('role', 'option'))
             .should(haveAttribute('id', 'alpine-combobox-option-2'))
             .should(haveAttribute('tabindex', '-1'))
-            .should(haveAttribute('aria-selected', 'false'))
+            .should(notHaveAttribute('aria-selected'))
 
         get('input')
+            .should(haveAttribute('role', 'combobox'))
+            .should(haveAttribute('aria-autocomplete', 'list'))
+            .should(haveAttribute('tabindex', '0'))
+            .should(haveAttribute('aria-expanded', 'true'))
+            .should(haveAttribute('aria-labelledby', 'alpine-combobox-label-1'))
+            .should(haveAttribute('aria-controls', 'alpine-combobox-options-1'))
+            .should(haveAttribute('aria-activedescendant', 'alpine-combobox-option-1'))
             .type('{downarrow}')
             .should(haveAttribute('aria-activedescendant', 'alpine-combobox-option-2'))
 
         get('[option="2"]')
-            .click()
             .should(haveAttribute('aria-selected', 'true'))
     },
 )
@@ -807,3 +989,317 @@ test('"static" prop',
         get('[normal-toggle]').should(haveText('Arlene Mccoy'))
     },
 )
+
+test('input reset',
+    [html`
+        <div
+            x-data="{
+                query: '',
+                selected: null,
+                people: [
+                    { id: 1, name: 'Wade Cooper' },
+                    { id: 2, name: 'Arlene Mccoy' },
+                    { id: 3, name: 'Devon Webb' },
+                    { id: 4, name: 'Tom Cook' },
+                    { id: 5, name: 'Tanya Fox', disabled: true },
+                    { id: 6, name: 'Hellen Schmidt' },
+                    { id: 7, name: 'Caroline Schultz' },
+                    { id: 8, name: 'Mason Heaney' },
+                    { id: 9, name: 'Claudie Smitham' },
+                    { id: 10, name: 'Emil Schaefer' },
+                ],
+                get filteredPeople() {
+                    return this.query === ''
+                        ? this.people
+                        : this.people.filter((person) => {
+                            return person.name.toLowerCase().includes(this.query.toLowerCase())
+                        })
+                },
+            }"
+        >
+            <div x-combobox x-model="selected">
+                <label x-combobox:label>Select person</label>
+
+                <div>
+                    <div>
+                        <input
+                            x-combobox:input
+                            :display-value="person => person.name"
+                            @change="query = $event.target.value"
+                            placeholder="Search..."
+                        />
+
+                        <button x-combobox:button>Toggle</button>
+                    </div>
+
+                    <div x-combobox:options>
+                        <ul>
+                            <template
+                                x-for="person in filteredPeople"
+                                :key="person.id"
+                                hidden
+                            >
+                                <li
+                                    x-combobox:option
+                                    :option="person.id"
+                                    :value="person"
+                                    :disabled="person.disabled"
+                                    x-text="person.name"
+                                >
+                                </li>
+                            </template>
+                        </ul>
+
+                        <p x-show="filteredPeople.length == 0">No people match your query.</p>
+                    </div>
+                </div>
+            </div>
+
+            <article>lorem ipsum</article>
+        </div>
+    `],
+    ({ get }) => {
+        // Test after closing with button
+        get('button').click()
+        get('input').type('w')
+        get('button').click()
+        get('input').should(haveValue(''))
+
+        // Test correct state after closing with ESC
+        get('button').click()
+        get('input').type('w')
+        get('input').type('{esc}')
+        get('input').should(haveValue(''))
+
+        // Test correct state after closing with TAB
+        get('button').click()
+        get('input').type('w')
+        get('input').tab()
+        get('input').should(haveValue(''))
+
+        // Test correct state after closing with external click
+        get('button').click()
+        get('input').type('w')
+        get('article').click()
+        get('input').should(haveValue(''))
+
+        // Select something
+        get('button').click()
+        get('ul').should(beVisible())
+        get('[option="2"]').click()
+        get('input').should(haveValue('Arlene Mccoy'))
+
+        // Test after closing with button
+        get('button').click()
+        get('input').type('w')
+        get('button').click()
+        get('input').should(haveValue('Arlene Mccoy'))
+
+        // Test correct state after closing with ESC and reopening
+        get('button').click()
+        get('input').type('w')
+        get('input').type('{esc}')
+        get('input').should(haveValue('Arlene Mccoy'))
+
+        // Test correct state after closing with TAB and reopening
+        get('button').click()
+        get('input').type('w')
+        get('input').tab()
+        get('input').should(haveValue('Arlene Mccoy'))
+
+        // Test correct state after closing with external click and reopening
+        get('button').click()
+        get('input').type('w')
+        get('article').click()
+        get('input').should(haveValue('Arlene Mccoy'))
+    },
+)
+
+test('combobox shows all options when opening',
+    [html`
+        <div
+            x-data="{
+                query: '',
+                selected: null,
+                people: [
+                    { id: 1, name: 'Wade Cooper' },
+                    { id: 2, name: 'Arlene Mccoy' },
+                    { id: 3, name: 'Devon Webb' },
+                    { id: 4, name: 'Tom Cook' },
+                    { id: 5, name: 'Tanya Fox', disabled: true },
+                    { id: 6, name: 'Hellen Schmidt' },
+                    { id: 7, name: 'Caroline Schultz' },
+                    { id: 8, name: 'Mason Heaney' },
+                    { id: 9, name: 'Claudie Smitham' },
+                    { id: 10, name: 'Emil Schaefer' },
+                ],
+                get filteredPeople() {
+                    return this.query === ''
+                        ? this.people
+                        : this.people.filter((person) => {
+                            return person.name.toLowerCase().includes(this.query.toLowerCase())
+                        })
+                },
+            }"
+        >
+            <div x-combobox x-model="selected">
+                <label x-combobox:label>Select person</label>
+
+                <div>
+                    <div>
+                        <input
+                            x-combobox:input
+                            :display-value="person => person.name"
+                            @change="query = $event.target.value"
+                            placeholder="Search..."
+                        />
+
+                        <button x-combobox:button>Toggle</button>
+                    </div>
+
+                    <div x-combobox:options>
+                        <ul>
+                            <template
+                                x-for="person in filteredPeople"
+                                :key="person.id"
+                                hidden
+                            >
+                                <li
+                                    x-combobox:option
+                                    :option="person.id"
+                                    :value="person"
+                                    :disabled="person.disabled"
+                                    x-text="person.name"
+                                >
+                                </li>
+                            </template>
+                        </ul>
+
+                        <p x-show="filteredPeople.length == 0">No people match your query.</p>
+                    </div>
+                </div>
+            </div>
+
+            <article>lorem ipsum</article>
+        </div>
+    `],
+    ({ get }) => {
+        get('button').click()
+        get('li').should(haveLength('10'))
+
+        // Test after closing with button and reopening
+        get('input').type('w').trigger('input')
+        get('li').should(haveLength('2'))
+        get('button').click()
+        get('button').click()
+        get('li').should(haveLength('10'))
+
+        // Test correct state after closing with ESC and reopening
+        get('input').type('w').trigger('input')
+        get('li').should(haveLength('2'))
+        get('input').type('{esc}')
+        get('button').click()
+        get('li').should(haveLength('10'))
+
+        // Test correct state after closing with TAB and reopening
+        get('input').type('w').trigger('input')
+        get('li').should(haveLength('2'))
+        get('input').tab()
+        get('button').click()
+        get('li').should(haveLength('10'))
+
+        // Test correct state after closing with external click and reopening
+        get('input').type('w').trigger('input')
+        get('li').should(haveLength('2'))
+        get('article').click()
+        get('button').click()
+        get('li').should(haveLength('10'))
+    },
+)
+
+test('active element logic when opening a combobox',
+    [html`
+        <div
+            x-data="{
+                query: '',
+                selected: null,
+                people: [
+                    { id: 1, name: 'Wade Cooper' },
+                    { id: 2, name: 'Arlene Mccoy' },
+                    { id: 3, name: 'Devon Webb' },
+                    { id: 4, name: 'Tom Cook' },
+                    { id: 5, name: 'Tanya Fox', disabled: true },
+                    { id: 6, name: 'Hellen Schmidt' },
+                    { id: 7, name: 'Caroline Schultz' },
+                    { id: 8, name: 'Mason Heaney' },
+                    { id: 9, name: 'Claudie Smitham' },
+                    { id: 10, name: 'Emil Schaefer' },
+                ],
+                get filteredPeople() {
+                    return this.query === ''
+                        ? this.people
+                        : this.people.filter((person) => {
+                            return person.name.toLowerCase().includes(this.query.toLowerCase())
+                        })
+                },
+            }"
+        >
+            <div x-combobox x-model="selected">
+                <label x-combobox:label>Select person</label>
+
+                <div>
+                    <div>
+                        <input
+                            x-combobox:input
+                            :display-value="person => person.name"
+                            @change="query = $event.target.value"
+                            placeholder="Search..."
+                        />
+
+                        <button x-combobox:button>Toggle</button>
+                    </div>
+
+                    <div x-combobox:options>
+                        <ul>
+                            <template
+                                x-for="person in filteredPeople"
+                                :key="person.id"
+                                hidden
+                            >
+                                <li
+                                    x-combobox:option
+                                    :option="person.id"
+                                    :value="person"
+                                    :disabled="person.disabled"
+                                    x-text="person.name"
+                                >
+                                </li>
+                            </template>
+                        </ul>
+
+                        <p x-show="filteredPeople.length == 0">No people match your query.</p>
+                    </div>
+                </div>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('button').click()
+        // First option is selected on opening if no preselection
+        get('ul').should(beVisible())
+        get('[option="1"]').should(haveAttribute('aria-selected', 'true'))
+        // First match is selected while typing
+        get('[option="4"]').should(notHaveAttribute('aria-selected'))
+        get('input').type('T')
+        get('input').trigger('change')
+        get('[option="4"]').should(haveAttribute('aria-selected', 'true'))
+        // Reset state and select option 3
+        get('button').click()
+        get('button').click()
+        get('[option="3"]').click()
+        // Previous selection is selected
+        get('button').click()
+        get('[option="4"]').should(notHaveAttribute('aria-selected'))
+        get('[option="3"]').should(haveAttribute('aria-selected', 'true'))
+    }
+)