Browse Source

Merge branch 'main' into livewire3

Caleb Porzio 2 years ago
parent
commit
0043bf1f74

+ 223 - 23
index.html

@@ -1,4 +1,5 @@
 <html>
 <html>
+<<<<<<< HEAD
     <!-- <script src="./packages/intersect/dist/cdn.js" defer></script>
     <!-- <script src="./packages/intersect/dist/cdn.js" defer></script>
     <script src="./packages/morph/dist/cdn.js" defer></script>
     <script src="./packages/morph/dist/cdn.js" defer></script>
     <script src="./packages/persist/dist/cdn.js"></script>
     <script src="./packages/persist/dist/cdn.js"></script>
@@ -118,33 +119,232 @@
     </div>
     </div>
 
 
     <!-- Play around here... -->
     <!-- Play around here... -->
+=======
+<script src="./packages/intersect/dist/cdn.js" defer></script>
+<script src="./packages/morph/dist/cdn.js" defer></script>
+<script src="./packages/history/dist/cdn.js"></script>
+<script src="./packages/persist/dist/cdn.js"></script>
+<script src="./packages/focus/dist/cdn.js"></script>
+<script src="./packages/mask/dist/cdn.js"></script>
+<script src="./packages/ui/dist/cdn.js" defer></script>
+<script src="./packages/alpinejs/dist/cdn.js" defer></script>
+<script src="//cdn.tailwindcss.com"></script>
+
+<div
+    x-data="{
+        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' },
+            { 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' },
+        ],
+        activePersons: [],
+        get queryPerson() {
+            if (! this.query) return null
+
+            return {
+                id: 11, name: this.query,
+            }
+        },
+        onSubmit(e) {
+            e.preventDefault()
+            console.log([...new FormData(e.currentTarget).entries()])
+        },
+        removePerson(person) {
+            this.activePersons = this.activePersons.filter((p) => p !== person)
+        }
+    }"
+    class="flex h-full w-screen justify-center space-x-4 bg-gray-50 p-12"
+>
+    <div class="w-full max-w-4xl">
+        <div class="space-y-1">
+            <form @submit="onSubmit">
+                <div x-combobox x-model="activePersons" name="people" multiple>
+                    <label x-combobox:label class="block text-sm font-medium leading-5 text-gray-700">
+                        Assigned to
+                    </label>
+>>>>>>> main
+
+                    <div class="relative">
+                        <div>Query: <span x-text="query"></span></div>
+                        <span class="inline-block w-full rounded-md shadow-sm">
+                            <div class="relative w-full cursor-default rounded-md border border-gray-300 bg-white py-2 pl-2 pr-10 text-left transition duration-150 ease-in-out focus-within:border-blue-700 focus-within:outline-none focus-within:ring-1 focus-within:ring-blue-700 sm:text-sm sm:leading-5">
+                                <span class="block flex flex-wrap gap-2">
+                                    <span x-show="activePersons.length === 0" class="p-0.5">Empty</span>
+                                    <template x-for="person in activePersons" :key="person.id">
+                                        <span class="flex items-center gap-1 rounded bg-blue-50 px-2 py-0.5">
+                                            <span x-text="person.name"></span>
+                                            <svg class="h-4 w-4 cursor-pointer" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" @click.stop.prevent="removePerson(person)">
+                                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
+                                            </svg>
+                                        </span>
+                                    </template>
+                                    <input x-combobox:input @change="query = $event.target.value" class="border-none p-0 focus:ring-0" placeholder="Search..." />
+                                </span>
+                                <button x-combobox:button class="absolute inset-y-0 right-0 flex items-center pr-2">
+                                    <svg class="h-5 w-5 text-gray-400" 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>
+                        </span>
+
+                        <div class="absolute mt-1 w-full rounded-md bg-white shadow-lg">
+                            <ul x-combobox:options hold class="shadow-xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5">
+                                <template
+                                    x-for="person in people.filter((person) =>
+                                        person.name.toLowerCase().includes(query.toLowerCase())
+                                    )"
+                                    :key="person.id"
+                                >
+                                    <li x-combobox:option :value="person" class="relative cursor-default select-none py-2 pl-3 pr-9 focus:outline-none" :class="$comboboxOption.isActive ? 'bg-indigo-600 text-white' : 'text-gray-900'">
+                                        <span x-text="person.name" class="block truncate" :class="{ 'font-semibold': $comboboxOption.isSelected, 'font-normal': !$comboboxOption.isSelected }">
+                                        </span>
+                                        <span x-show="$comboboxOption.isSelected" class="absolute inset-y-0 right-0 flex items-center pr-4" :class="{ 'text-white': $comboboxOption.isActive, 'text-indigo-600': !$comboboxOption.isActive }">
+                                            <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
+                                                <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
+                                            </svg>
+                                        </span>
+                                    </li>
+                                </template>
+
+                                <!-- <template x-if="queryPerson">
+                                    <li x-combobox:option :value="queryPerson" class="relative cursor-default select-none py-2 pl-3 pr-9 focus:outline-none" :class="$comboboxOption.isActive ? 'bg-indigo-600 text-white' : 'text-gray-900'">
+                                        <span x-text="'Create ' + queryPerson.name" class="block truncate" :class="{ 'font-semibold': $comboboxOption.isSelected, 'font-normal': !$comboboxOption.isSelected }">
+                                        </span>
+                                        <span x-show="$comboboxOption.isSelected" class="absolute inset-y-0 right-0 flex items-center pr-4" :class="{ 'text-white': $comboboxOption.isActive, 'text-indigo-600': !$comboboxOption.isActive }">
+                                            <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
+                                                <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
+                                            </svg>
+                                        </span>
+                                    </li>
+                                </template> -->
+                            </ul>
+                        </div>
+                    </div>
+                </div>
+                <button class="mt-2 inline-flex items-center rounded border border-gray-300 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
+                    Submit
+                </button>
+            </form>
+        </div>
+    </div>
+</div>
 
 
-    <div x-data>
-        <div id="thing" x-yo>i do not belong here...</div>
-
-        <br>
-        <br>
-        <br>
-        <br>
+<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())
+                })
+        }
+    }"
+
+    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>
+
+        <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>
 
 
-        <button @click="document.getElementById('thing').remove()">remove</button>
+            <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>
+
+                            <span x-show="$comboboxOption.isSelected" class="text-cyan-600 font-bold">&check;</span>
+                        </li>
+                    </template>
+                </ul>
+
+                <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>
+</div>
+
 
 
-    <script>
-        document.addEventListener('alpine:init', () => {
-            Alpine.directive('yo', (el, {}, { cleanup }) => {
-                cleanup(() => {
-                    console.log('removed')
-                })
-            })
-        })
-    </script>
 
 
-    <div x-data="{ users: [{ name: 'lebowski' }] }">
-        <template x-for="(user, idx) in users">
-            <span x-text="users[idx].name" x-yo></span>
-        </template>
 
 
-        <button @click="users = []">Reset</button>
-    </div>
 </html>
 </html>

+ 29 - 11
package-lock.json

@@ -7825,17 +7825,19 @@
             }
             }
         },
         },
         "packages/alpinejs": {
         "packages/alpinejs": {
-            "version": "3.12.0",
+            "version": "3.12.3",
             "license": "MIT",
             "license": "MIT",
             "dependencies": {
             "dependencies": {
                 "@vue/reactivity": "~3.1.1"
                 "@vue/reactivity": "~3.1.1"
             }
             }
         },
         },
         "packages/collapse": {
         "packages/collapse": {
-            "version": "3.12.0",
+            "name": "@alpinejs/collapse",
+            "version": "3.12.3",
             "license": "MIT"
             "license": "MIT"
         },
         },
         "packages/csp": {
         "packages/csp": {
+            "name": "@alpinejs/csp",
             "version": "3.0.0-alpha.0",
             "version": "3.0.0-alpha.0",
             "license": "MIT",
             "license": "MIT",
             "dependencies": {
             "dependencies": {
@@ -7843,17 +7845,21 @@
             }
             }
         },
         },
         "packages/docs": {
         "packages/docs": {
-            "version": "3.12.0-revision.1",
+            "name": "@alpinejs/docs",
+            "version": "3.12.3-revision.1",
             "license": "MIT"
             "license": "MIT"
         },
         },
         "packages/focus": {
         "packages/focus": {
-            "version": "3.12.0",
+            "name": "@alpinejs/focus",
+            "version": "3.12.1",
             "license": "MIT",
             "license": "MIT",
             "dependencies": {
             "dependencies": {
-                "focus-trap": "^6.6.1"
+                "focus-trap": "^6.9.4",
+                "tabbable": "^5.3.3"
             }
             }
         },
         },
         "packages/history": {
         "packages/history": {
+            "name": "@alpinejs/history",
             "version": "3.0.0-alpha.0",
             "version": "3.0.0-alpha.0",
             "license": "MIT",
             "license": "MIT",
             "dependencies": {
             "dependencies": {
@@ -7861,18 +7867,22 @@
             }
             }
         },
         },
         "packages/intersect": {
         "packages/intersect": {
-            "version": "3.12.0",
+            "name": "@alpinejs/intersect",
+            "version": "3.12.3",
             "license": "MIT"
             "license": "MIT"
         },
         },
         "packages/mask": {
         "packages/mask": {
-            "version": "3.12.0",
+            "name": "@alpinejs/mask",
+            "version": "3.12.3",
             "license": "MIT"
             "license": "MIT"
         },
         },
         "packages/morph": {
         "packages/morph": {
-            "version": "3.12.0",
+            "name": "@alpinejs/morph",
+            "version": "3.12.3",
             "license": "MIT"
             "license": "MIT"
         },
         },
         "packages/navigate": {
         "packages/navigate": {
+            "name": "@alpinejs/navigate",
             "version": "3.10.2",
             "version": "3.10.2",
             "license": "MIT",
             "license": "MIT",
             "dependencies": {
             "dependencies": {
@@ -7880,12 +7890,20 @@
             }
             }
         },
         },
         "packages/persist": {
         "packages/persist": {
-            "version": "3.12.0",
+            "name": "@alpinejs/persist",
+            "version": "3.12.3",
             "license": "MIT"
             "license": "MIT"
         },
         },
         "packages/ui": {
         "packages/ui": {
-            "version": "3.12.0-beta.0",
-            "license": "MIT"
+            "name": "@alpinejs/ui",
+            "version": "3.12.3-beta.0",
+            "license": "MIT",
+            "devDependencies": {
+                "alpinejs": "file:../alpinejs"
+            },
+            "peerDependencies": {
+                "alpinejs": "^3.10.0"
+            }
         }
         }
     }
     }
 }
 }

+ 1 - 1
packages/alpinejs/package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "alpinejs",
     "name": "alpinejs",
-    "version": "3.12.2",
+    "version": "3.12.3",
     "description": "The rugged, minimal JavaScript framework",
     "description": "The rugged, minimal JavaScript framework",
     "homepage": "https://alpinejs.dev",
     "homepage": "https://alpinejs.dev",
     "repository": {
     "repository": {

+ 2 - 1
packages/alpinejs/src/alpine.js

@@ -7,7 +7,7 @@ import { setEvaluator, evaluate, evaluateLater, dontAutoEvaluateFunctions } from
 import { transition } from './directives/x-transition'
 import { transition } from './directives/x-transition'
 import { clone, skipDuringClone, onlyDuringClone } from './clone'
 import { clone, skipDuringClone, onlyDuringClone } from './clone'
 import { interceptor } from './interceptor'
 import { interceptor } from './interceptor'
-import { getBinding as bound } from './utils/bind'
+import { getBinding as bound, extractProp } from './utils/bind'
 import { debounce } from './utils/debounce'
 import { debounce } from './utils/debounce'
 import { throttle } from './utils/throttle'
 import { throttle } from './utils/throttle'
 import { setStyles } from './utils/styles'
 import { setStyles } from './utils/styles'
@@ -45,6 +45,7 @@ let Alpine = {
     interceptInit,
     interceptInit,
     setEvaluator,
     setEvaluator,
     mergeProxies,
     mergeProxies,
+    extractProp,
     findClosest,
     findClosest,
     onElRemoved,
     onElRemoved,
     closestRoot,
     closestRoot,

+ 18 - 2
packages/alpinejs/src/directives/x-bind.js

@@ -6,7 +6,7 @@ import { applyBindingsObject, injectBindingProviders } from '../binds'
 
 
 mapAttributes(startingWith(':', into(prefix('bind:'))))
 mapAttributes(startingWith(':', into(prefix('bind:'))))
 
 
-directive('bind', (el, { value, modifiers, expression, original }, { effect }) => {
+let handler = (el, { value, modifiers, expression, original }, { effect }) => {
     if (! value) {
     if (! value) {
         let bindingProviders = {}
         let bindingProviders = {}
         injectBindingProviders(bindingProviders)
         injectBindingProviders(bindingProviders)
@@ -22,6 +22,10 @@ directive('bind', (el, { value, modifiers, expression, original }, { effect }) =
 
 
     if (value === 'key') return storeKeyForXFor(el, expression)
     if (value === 'key') return storeKeyForXFor(el, expression)
 
 
+    if (el._x_inlineBindings && el._x_inlineBindings[value] && el._x_inlineBindings[value].extract) {
+        return
+    }
+
     let evaluate = evaluateLater(el, expression)
     let evaluate = evaluateLater(el, expression)
 
 
     effect(() => evaluate(result => {
     effect(() => evaluate(result => {
@@ -32,7 +36,19 @@ directive('bind', (el, { value, modifiers, expression, original }, { effect }) =
 
 
         mutateDom(() => bind(el, value, result, modifiers))
         mutateDom(() => bind(el, value, result, modifiers))
     }))
     }))
-})
+}
+
+// @todo: see if I can take advantage of the object created here inside the
+// non-inline handler above so we're not duplicating work twice...
+handler.inline = (el, { value, modifiers, expression }) => {
+    if (! value) return;
+
+    if (! el._x_inlineBindings) el._x_inlineBindings = {}
+
+    el._x_inlineBindings[value] = { expression, extract: false }
+}
+
+directive('bind', handler)
 
 
 function storeKeyForXFor(el, expression) {
 function storeKeyForXFor(el, expression) {
     el._x_keyExpression = expression
     el._x_keyExpression = expression

+ 4 - 2
packages/alpinejs/src/directives/x-model.js

@@ -70,8 +70,10 @@ directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
         setValue(getInputValue(el, modifiers, e, getValue()))
         setValue(getInputValue(el, modifiers, e, getValue()))
     })
     })
     
     
-    if (modifiers.includes('fill') && [null, ''].includes(getValue())) {
-        el.dispatchEvent(new Event(event, {}));
+    if (modifiers.includes('fill'))
+        if ([null, ''].includes(getValue())
+            || (el.type === 'checkbox' && Array.isArray(getValue()))) {
+            el.dispatchEvent(new Event(event, {}));
     }
     }
     // Register the listener removal callback on the element, so that
     // Register the listener removal callback on the element, so that
     // in addition to the cleanup function, x-modelable may call it.
     // in addition to the cleanup function, x-modelable may call it.

+ 3 - 1
packages/alpinejs/src/evaluator.js

@@ -9,9 +9,11 @@ export function dontAutoEvaluateFunctions(callback) {
 
 
     shouldAutoEvaluateFunctions = false
     shouldAutoEvaluateFunctions = false
 
 
-    callback()
+    let result = callback()
 
 
     shouldAutoEvaluateFunctions = cache
     shouldAutoEvaluateFunctions = cache
+
+    return result
 }
 }
 
 
 export function evaluate(el, expression, extras = {}) {
 export function evaluate(el, expression, extras = {}) {

+ 24 - 2
packages/alpinejs/src/utils/bind.js

@@ -1,3 +1,4 @@
+import { dontAutoEvaluateFunctions, evaluate } from '../evaluator'
 import { reactive } from '../reactivity'
 import { reactive } from '../reactivity'
 import { setClasses } from './classes'
 import { setClasses } from './classes'
 import { setStyles } from './styles'
 import { setStyles } from './styles'
@@ -22,9 +23,9 @@ export default function bind(el, name, value, modifiers = []) {
         case 'class':
         case 'class':
             bindClasses(el, value)
             bindClasses(el, value)
             break;
             break;
-        
+
         // 'selected' and 'checked' are special attributes that aren't necessarily
         // 'selected' and 'checked' are special attributes that aren't necessarily
-        // synced with their corresponding properties when updated, so both the 
+        // synced with their corresponding properties when updated, so both the
         // attribute and property need to be updated when bound.
         // attribute and property need to be updated when bound.
         case 'selected':
         case 'selected':
         case 'checked':
         case 'checked':
@@ -151,6 +152,27 @@ export function getBinding(el, name, fallback) {
     // First let's get it out of Alpine bound data.
     // First let's get it out of Alpine bound data.
     if (el._x_bindings && el._x_bindings[name] !== undefined) return el._x_bindings[name]
     if (el._x_bindings && el._x_bindings[name] !== undefined) return el._x_bindings[name]
 
 
+    return getAttributeBinding(el, name, fallback)
+}
+
+export function extractProp(el, name, fallback, extract = true) {
+    // First let's get it out of Alpine bound data.
+    if (el._x_bindings && el._x_bindings[name] !== undefined) return el._x_bindings[name]
+
+    if (el._x_inlineBindings && el._x_inlineBindings[name] !== undefined) {
+        let binding = el._x_inlineBindings[name]
+
+        binding.extract = extract
+
+        return dontAutoEvaluateFunctions(() => {
+            return evaluate(el, binding.expression)
+        })
+    }
+
+    return getAttributeBinding(el, name, fallback)
+}
+
+function getAttributeBinding(el, name, fallback) {
     // If not, we'll return the literal attribute.
     // If not, we'll return the literal attribute.
     let attr = el.getAttribute(name)
     let attr = el.getAttribute(name)
 
 

+ 1 - 1
packages/collapse/package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "@alpinejs/collapse",
     "name": "@alpinejs/collapse",
-    "version": "3.12.2",
+    "version": "3.12.3",
     "description": "Collapse and expand elements with robust animations",
     "description": "Collapse and expand elements with robust animations",
     "homepage": "https://alpinejs.dev/plugins/collapse",
     "homepage": "https://alpinejs.dev/plugins/collapse",
     "repository": {
     "repository": {

+ 1 - 1
packages/docs/package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "@alpinejs/docs",
     "name": "@alpinejs/docs",
-    "version": "3.12.2-revision.1",
+    "version": "3.12.3-revision.1",
     "description": "The documentation for Alpine",
     "description": "The documentation for Alpine",
     "author": "Caleb Porzio",
     "author": "Caleb Porzio",
     "license": "MIT"
     "license": "MIT"

+ 1 - 1
packages/docs/src/en/directives/on.md

@@ -303,7 +303,7 @@ If you are listening for touch events, it's important to add `.passive` to your
 
 
 Add this modifier if you want to execute this listener in the event's capturing phase, e.g. before the event bubbles from the target element up the DOM.
 Add this modifier if you want to execute this listener in the event's capturing phase, e.g. before the event bubbles from the target element up the DOM.
 
 
-```
+```alpine
 <div @click.capture="console.log('I will log first')">
 <div @click.capture="console.log('I will log first')">
     <button @click="console.log('I will log second')"></button>
     <button @click="console.log('I will log second')"></button>
 </div>
 </div>

+ 1 - 1
packages/docs/src/en/essentials/installation.md

@@ -33,7 +33,7 @@ This is by far the simplest way to get started with Alpine. Include the followin
 Notice the `@3.x.x` in the provided CDN link. This will pull the latest version of Alpine version 3. For stability in production, it's recommended that you hardcode the latest version in the CDN link.
 Notice the `@3.x.x` in the provided CDN link. This will pull the latest version of Alpine version 3. For stability in production, it's recommended that you hardcode the latest version in the CDN link.
 
 
 ```alpine
 ```alpine
-<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.12.2/dist/cdn.min.js"></script>
+<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.12.3/dist/cdn.min.js"></script>
 ```
 ```
 
 
 That's it! Alpine is now available for use inside your page.
 That's it! Alpine is now available for use inside your page.

+ 1 - 1
packages/docs/src/en/magics/watch.md

@@ -39,7 +39,7 @@ When the `<button>` is pressed, `foo.bar` will be set to "bob", and "bob" will b
 <a name="deep-watching"></a>
 <a name="deep-watching"></a>
 ### Deep watching
 ### Deep watching
 
 
-`$watch` will automatically watches from changes at any level but you should keep in mind that, when a change is detected, the watcher will return the value of the observed property, not the value of the subproperty that has changed.
+`$watch` automatically watches from changes at any level but you should keep in mind that, when a change is detected, the watcher will return the value of the observed property, not the value of the subproperty that has changed.
 
 
 ```alpine
 ```alpine
 <div x-data="{ foo: { bar: 'baz' }}" x-init="$watch('foo', (value, oldValue) => console.log(value, oldValue))">
 <div x-data="{ foo: { bar: 'baz' }}" x-init="$watch('foo', (value, oldValue) => console.log(value, oldValue))">

+ 3 - 2
packages/focus/package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "@alpinejs/focus",
     "name": "@alpinejs/focus",
-    "version": "3.12.2",
+    "version": "3.12.3",
     "description": "Manage focus within a page",
     "description": "Manage focus within a page",
     "homepage": "https://alpinejs.dev/plugins/focus",
     "homepage": "https://alpinejs.dev/plugins/focus",
     "repository": {
     "repository": {
@@ -14,6 +14,7 @@
     "module": "dist/module.esm.js",
     "module": "dist/module.esm.js",
     "unpkg": "dist/cdn.min.js",
     "unpkg": "dist/cdn.min.js",
     "dependencies": {
     "dependencies": {
-        "focus-trap": "^6.6.1"
+        "focus-trap": "^6.9.4",
+        "tabbable": "^5.3.3"
     }
     }
 }
 }

+ 1 - 1
packages/intersect/package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "@alpinejs/intersect",
     "name": "@alpinejs/intersect",
-    "version": "3.12.2",
+    "version": "3.12.3",
     "description": "Trigger JavaScript when an element enters the viewport",
     "description": "Trigger JavaScript when an element enters the viewport",
     "homepage": "https://alpinejs.dev/plugins/intersect",
     "homepage": "https://alpinejs.dev/plugins/intersect",
     "repository": {
     "repository": {

+ 1 - 1
packages/mask/package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "@alpinejs/mask",
     "name": "@alpinejs/mask",
-    "version": "3.12.2",
+    "version": "3.12.3",
     "description": "An Alpine plugin for input masking",
     "description": "An Alpine plugin for input masking",
     "homepage": "https://alpinejs.dev/plugins/mask",
     "homepage": "https://alpinejs.dev/plugins/mask",
     "repository": {
     "repository": {

+ 1 - 1
packages/morph/package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "@alpinejs/morph",
     "name": "@alpinejs/morph",
-    "version": "3.12.2",
+    "version": "3.12.3",
     "description": "Diff and patch a block of HTML on a page with an HTML template",
     "description": "Diff and patch a block of HTML on a page with an HTML template",
     "homepage": "https://alpinejs.dev/plugins/morph",
     "homepage": "https://alpinejs.dev/plugins/morph",
     "repository": {
     "repository": {

+ 6 - 0
packages/morph/src/dom.js

@@ -14,12 +14,18 @@ export let dom = {
     replace(children, old, replacement) {
     replace(children, old, replacement) {
         let index = children.indexOf(old)
         let index = children.indexOf(old)
 
 
+        let replacementIndex = children.indexOf(old)
+
         if (index === -1) throw 'Cant find element in children'
         if (index === -1) throw 'Cant find element in children'
 
 
         old.replaceWith(replacement)
         old.replaceWith(replacement)
 
 
         children[index] = replacement
         children[index] = replacement
 
 
+        if (replacementIndex) {
+            children.splice(replacementIndex, 1)
+        }
+
         return children
         return children
     },
     },
     before(children, reference, subject) {
     before(children, reference, subject) {

+ 1 - 1
packages/morph/src/morph.js

@@ -134,7 +134,7 @@ export function morph(from, toHtml, options) {
 
 
     function patchChildren(fromChildren, toChildren, appendFn) {
     function patchChildren(fromChildren, toChildren, appendFn) {
         // I think I can get rid of this for now:
         // I think I can get rid of this for now:
-        let fromKeyDomNodeMap = {} // keyToMap(fromChildren)
+        let fromKeyDomNodeMap = keyToMap(fromChildren)
         let fromKeyHoldovers = {}
         let fromKeyHoldovers = {}
 
 
         let currentTo = dom.first(toChildren)
         let currentTo = dom.first(toChildren)

+ 1 - 1
packages/persist/package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "@alpinejs/persist",
     "name": "@alpinejs/persist",
-    "version": "3.12.2",
+    "version": "3.12.3",
     "description": "Persist Alpine data across page loads",
     "description": "Persist Alpine data across page loads",
     "homepage": "https://alpinejs.dev/plugins/persist",
     "homepage": "https://alpinejs.dev/plugins/persist",
     "repository": {
     "repository": {

+ 7 - 2
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "@alpinejs/ui",
     "name": "@alpinejs/ui",
-    "version": "3.12.1-beta.0",
+    "version": "3.12.3-beta.0",
     "description": "Headless UI components for Alpine",
     "description": "Headless UI components for Alpine",
     "homepage": "https://alpinejs.dev/components#headless",
     "homepage": "https://alpinejs.dev/components#headless",
     "repository": {
     "repository": {
@@ -13,5 +13,10 @@
     "main": "dist/module.cjs.js",
     "main": "dist/module.cjs.js",
     "module": "dist/module.esm.js",
     "module": "dist/module.esm.js",
     "unpkg": "dist/cdn.min.js",
     "unpkg": "dist/cdn.min.js",
-    "dependencies": {}
+    "devDependencies": {
+        "alpinejs": "file:../alpinejs"
+    },
+    "peerDependencies": {
+        "alpinejs": "^3.10.0"
+    }
 }
 }

+ 373 - 429
packages/ui/src/combobox.js

@@ -1,3 +1,4 @@
+import { generateContext, renderHiddenInputs } from './list-context'
 
 
 export default function (Alpine) {
 export default function (Alpine) {
     Alpine.directive('combobox', (el, directive, { evaluate }) => {
     Alpine.directive('combobox', (el, directive, { evaluate }) => {
@@ -9,57 +10,250 @@ export default function (Alpine) {
         else                                         handleRoot(el, Alpine)
         else                                         handleRoot(el, Alpine)
     }).before('bind')
     }).before('bind')
 
 
-    Alpine.magic('comboboxOption', el => {
-        let $data = Alpine.$data(el)
+    Alpine.magic('combobox', el => {
+        let data = Alpine.$data(el)
+
+        return {
+            get value() {
+                return data.__value
+            },
+            get isOpen() {
+                return data.__isOpen
+            },
+            get isDisabled() {
+                return data.__isDisabled
+            },
+            get activeOption() {
+                let active = data.__context?.getActiveItem()
+
+                return active && active.value
+            },
+            get activeIndex() {
+                let active = data.__context?.getActiveItem()
+
+                if (active) {
+                    return Object.values(Alpine.raw(data.__context.items)).findIndex(i => Alpine.raw(active) == Alpine.raw(i))
+                }
 
 
-        return $data.$item
+                return null
+            },
+        }
     })
     })
 
 
-    registerListStuff(Alpine)
+    Alpine.magic('comboboxOption', el => {
+        let data = Alpine.$data(el)
+
+        let optionEl = Alpine.findClosest(el, i => i.__optionKey)
+
+        if (! optionEl) throw 'No x-combobox:option directive found...'
+
+        return {
+            get isActive() {
+                return data.__context.isActiveKey(optionEl.__optionKey)
+            },
+            get isSelected() {
+                return data.__isSelected(optionEl)
+            },
+            get isDisabled() {
+                return data.__context.isDisabled(optionEl.__optionKey)
+            },
+        }
+    })
 }
 }
 
 
 function handleRoot(el, Alpine) {
 function handleRoot(el, Alpine) {
     Alpine.bind(el, {
     Alpine.bind(el, {
-        'x-id'() { return ['headlessui-combobox-button', 'headlessui-combobox-options', 'headlessui-combobox-label'] },
-        'x-list': '__value',
+        // Setup...
+        'x-id'() { return ['alpine-combobox-button', 'alpine-combobox-options', 'alpine-combobox-label'] },
         'x-modelable': '__value',
         'x-modelable': '__value',
+
+        // Initialize...
         'x-data'() {
         'x-data'() {
             return {
             return {
-                init() {
-                    this.$nextTick(() => {
-                        this.syncInputValue()
+                /**
+                 * Combobox state...
+                 */
+                __ready: false,
+                __value: null,
+                __isOpen: false,
+                __context: undefined,
+                __isMultiple: undefined,
+                __isStatic: false,
+                __isDisabled: undefined,
+                __displayValue: undefined,
+                __compareBy: null,
+                __inputName: null,
+                __isTyping: false,
+                __hold: false,
 
 
-                        Alpine.effect(() => this.syncInputValue())
+                /**
+                 * Combobox initialization...
+                 */
+                init() {
+                    this.__isMultiple = Alpine.extractProp(el, 'multiple', false)
+                    this.__isDisabled = Alpine.extractProp(el, 'disabled', false)
+                    this.__inputName = Alpine.extractProp(el, 'name', null)
+                    this.__nullable = Alpine.extractProp(el, 'nullable', false)
+                    this.__compareBy = Alpine.extractProp(el, 'by')
+
+                    this.__context = generateContext(this.__isMultiple, 'vertical', () => this.__activateSelectedOrFirst())
+
+                    let defaultValue = Alpine.extractProp(el, 'default-value', this.__isMultiple ? [] : null)
+
+                    this.__value = defaultValue
+
+                    // We have to wait again until after the "ready" processes are finished
+                    // to settle up currently selected Values (this prevents this next bit
+                    // of code from running multiple times on startup...)
+                    queueMicrotask(() => {
+                        Alpine.effect(() => {
+                            // Everytime the value changes, we need to re-render the hidden inputs,
+                            // if a user passed the "name" prop...
+                            this.__inputName && renderHiddenInputs(this.$el, this.__inputName, this.__value)
+                        })
                     })
                     })
                 },
                 },
-                __value: null,
-                __disabled: false,
-                __static: false,
-                __hold: false,
-                __displayValue: i => i,
-                __isOpen: false,
-                __optionsEl: null,
+                __startTyping() {
+                    this.__isTyping = true
+                },
+                __stopTyping() {
+                    this.__isTyping = false
+                },
+                __resetInput() {
+                    let input = this.$refs.__input
+
+                    if (! input) return
+
+                    let value = this.__getCurrentValue()
+
+                    input.value = value
+                },
+                __getCurrentValue() {
+                    if (! this.$refs.__input) return ''
+                    if (! this.__value) return ''
+                    if (this.__displayValue) return this.__displayValue(this.__value)
+                    if (typeof this.__value === 'string') return this.__value
+                    return ''
+                },
                 __open() {
                 __open() {
-                    // @todo handle disabling the entire combobox.
                     if (this.__isOpen) return
                     if (this.__isOpen) return
                     this.__isOpen = true
                     this.__isOpen = true
 
 
-                    this.$list.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 })
+                        this.__activateSelectedOrFirst()
+                    })
                 },
                 },
                 __close() {
                 __close() {
-                    this.syncInputValue()
+                    this.__isOpen = false
 
 
-                    if (this.__static) return
+                    this.__context.deactivate()
+                },
+                __activateSelectedOrFirst(activateSelected = true) {
                     if (! this.__isOpen) return
                     if (! this.__isOpen) return
 
 
-                    this.__isOpen = false
-                    this.$list.active = null
+                    if (this.__context.hasActive() && this.__context.wasActivatedByKeyPress()) return
+
+                    let firstSelectedValue
+
+                    if (this.__isMultiple) {
+                        let selectedItem = this.__context.getItemsByValues(this.__value)
+
+                        firstSelectedValue = selectedItem.length ? selectedItem[0].value : null
+                    } else {
+                        firstSelectedValue = this.__value
+                    }
+
+                    let firstSelected = null
+                    if (activateSelected && firstSelectedValue) {
+                        firstSelected = this.__context.getItemByValue(firstSelectedValue)
+                    }
+
+                    if (firstSelected) {
+                        this.__context.activateAndScrollToKey(firstSelected.key)
+                        return
+                    }
+
+                    this.__context.activateAndScrollToKey(this.__context.firstKey())
+                },
+                __selectActive() {
+                    let active = this.__context.getActiveItem()
+                    if (active) this.__toggleSelected(active.value)
                 },
                 },
-                syncInputValue() {
-                    if (this.$list.selected) this.$refs.__input.value = this.__displayValue(this.$list.selected)
+                __selectOption(el) {
+                    let item = this.__context.getItemByEl(el)
+
+                    if (item) this.__toggleSelected(item.value)
+                },
+                __isSelected(el) {
+                    let item = this.__context.getItemByEl(el)
+
+                    if (! item) return false
+                    if (! item.value) return false
+
+                    return this.__hasSelected(item.value)
+                },
+                __toggleSelected(value) {
+                    if (! this.__isMultiple) {
+                        this.__value = value
+
+                        return
+                    }
+
+                    let index = this.__value.findIndex(j => this.__compare(j, value))
+
+                    if (index === -1) {
+                        this.__value.push(value)
+                    } else {
+                        this.__value.splice(index, 1)
+                    }
+                },
+                __hasSelected(value) {
+                    if (! this.__isMultiple) return this.__compare(this.__value, value)
+
+                    return this.__value.some(i => this.__compare(i, value))
+                },
+                __compare(a, b) {
+                    let by = this.__compareBy
+
+                    if (! by) by = (a, b) => Alpine.raw(a) === Alpine.raw(b)
+
+                    if (typeof by === 'string') {
+                        let property = by
+                        by = (a, b) => a[property] === b[property]
+                    }
+
+                    return by(a, b)
                 },
                 },
             }
             }
         },
         },
+        // Register event listeners..
         '@mousedown.window'(e) {
         '@mousedown.window'(e) {
             if (
             if (
                 !! ! this.$refs.__input.contains(e.target)
                 !! ! this.$refs.__input.contains(e.target)
@@ -67,6 +261,7 @@ function handleRoot(el, Alpine) {
                 && ! this.$refs.__options.contains(e.target)
                 && ! this.$refs.__options.contains(e.target)
             ) {
             ) {
                 this.__close()
                 this.__close()
+                this.__resetInput()
             }
             }
         }
         }
     })
     })
@@ -74,54 +269,116 @@ function handleRoot(el, Alpine) {
 
 
 function handleInput(el, Alpine) {
 function handleInput(el, Alpine) {
     Alpine.bind(el, {
     Alpine.bind(el, {
+        // Setup...
         'x-ref': '__input',
         'x-ref': '__input',
-        ':id'() { return this.$id('headlessui-combobox-input') },
+        ':id'() { return this.$id('alpine-combobox-input') },
+
+        // Accessibility attributes...
         'role': 'combobox',
         'role': 'combobox',
         'tabindex': '0',
         'tabindex': '0',
-        ':aria-controls'() { return this.$data.__optionsEl && this.$data.__optionsEl.id },
-        ':aria-expanded'() { return this.$data.__disabled ? undefined : this.$data.__isOpen },
-        ':aria-activedescendant'() { return this.$data.$list.activeEl ? this.$data.$list.activeEl.id : null },
+        '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) },
+        ':aria-expanded'() { return this.$data.__isDisabled ? undefined : this.$data.__isOpen },
+        ':aria-multiselectable'() { return this.$data.__isMultiple ? true : undefined },
+        ':aria-activedescendant'() {
+            if (! this.$data.__context.hasActive()) return
+
+            let active = this.$data.__context.getActiveItem()
+
+            return active ? active.el.id : null
+        },
         ':aria-labelledby'() { return this.$refs.__label ? this.$refs.__label.id : (this.$refs.__button ? this.$refs.__button.id : null) },
         ':aria-labelledby'() { return this.$refs.__label ? this.$refs.__label.id : (this.$refs.__button ? this.$refs.__button.id : null) },
+
+        // Initialize...
         'x-init'() {
         'x-init'() {
-            queueMicrotask(() => {
-                Alpine.effect(() => {
-                    this.$data.__disabled = Alpine.bound(this.$el, 'disabled', false)
-                })
+            let displayValueFn = Alpine.extractProp(this.$el, 'display-value')
+            if (displayValueFn) this.$data.__displayValue = displayValueFn
+        },
 
 
-                let displayValueFn = Alpine.bound(this.$el, 'display-value')
-                if (displayValueFn) this.$data.__displayValue = displayValueFn
-            })
+        // Register listeners...
+        '@input.stop'(e) {
+            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.__stopTyping()
+
+            if (! this.$data.__isMultiple) {
+                this.$data.__close()
+                this.$data.__resetInput()
+            }
         },
         },
-        '@input.stop'() { this.$data.__open(); this.$dispatch('change') },
-        '@change.stop'() {},
-        '@keydown.enter.prevent.stop'() { this.$list.selectActive(); this.$data.__close() },
-        '@keydown'(e) { this.$list.handleKeyboardNavigation(e) },
-        '@keydown.down'(e) { if(! this.$data.__isOpen) this.$data.__open(); },
-        '@keydown.up'(e) { if(! this.$data.__isOpen) this.$data.__open(); },
         '@keydown.escape.prevent'(e) {
         '@keydown.escape.prevent'(e) {
             if (! this.$data.__static) e.stopPropagation()
             if (! this.$data.__static) e.stopPropagation()
 
 
+            this.$data.__stopTyping()
             this.$data.__close()
             this.$data.__close()
+            this.$data.__resetInput()
+
+        },
+        '@keydown.tab'() {
+            this.$data.__stopTyping()
+            if (this.$data.__isOpen) { this.$data.__close() }
+            this.$data.__resetInput()
+        },
+        '@keydown.backspace'(e) {
+            if (this.$data.__isMultiple) return
+            if (! this.$data.__nullable) return
+
+            let input = e.target
+
+            requestAnimationFrame(() => {
+                if (input.value === '') {
+                    this.$data.__value = null
+
+                    let options = this.$refs.__options
+                    if (options) {
+                        options.scrollTop = 0
+                    }
+
+                    this.$data.__context.deactivate()
+                }
+            })
         },
         },
-        '@keydown.tab'() { if (this.$data.__isOpen) { this.$list.selectActive(); this.$data.__close() }},
     })
     })
 }
 }
 
 
 function handleButton(el, Alpine) {
 function handleButton(el, Alpine) {
     Alpine.bind(el, {
     Alpine.bind(el, {
+        // Setup...
         'x-ref': '__button',
         'x-ref': '__button',
-        ':id'() { return this.$id('headlessui-combobox-button') },
+        ':id'() { return this.$id('alpine-combobox-button') },
+
+        // Accessibility attributes...
         'aria-haspopup': 'true',
         'aria-haspopup': 'true',
+        // 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) },
         ':aria-labelledby'() { return this.$refs.__label ? [this.$refs.__label.id, this.$el.id].join(' ') : null },
         ':aria-labelledby'() { return this.$refs.__label ? [this.$refs.__label.id, this.$el.id].join(' ') : null },
-        ':aria-expanded'() { return this.$data.__disabled ? null : this.$data.__isOpen },
-        ':aria-controls'() { return this.$data.__optionsEl ? this.$data.__optionsEl.id : null },
-        ':disabled'() { return this.$data.__disabled },
+        ':aria-expanded'() { return this.$data.__isDisabled ? null : this.$data.__isOpen },
+        ':disabled'() { return this.$data.__isDisabled },
         'tabindex': '-1',
         'tabindex': '-1',
+
+        // Initialize....
         'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && ! this.$el.hasAttribute('type')) this.$el.type = 'button' },
         'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && ! this.$el.hasAttribute('type')) this.$el.type = 'button' },
+
+        // Register listeners...
         '@click'(e) {
         '@click'(e) {
-            if (this.$data.__disabled) return
+            if (this.$data.__isDisabled) return
             if (this.$data.__isOpen) {
             if (this.$data.__isOpen) {
                 this.$data.__close()
                 this.$data.__close()
+                this.$data.__resetInput()
             } else {
             } else {
                 e.preventDefault()
                 e.preventDefault()
                 this.$data.__open()
                 this.$data.__open()
@@ -129,422 +386,109 @@ function handleButton(el, Alpine) {
 
 
             this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
             this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
         },
         },
-        '@keydown.down.prevent.stop'() {
-            if (! this.$data.__isOpen) {
-                this.$data.__open()
-                this.$list.activateSelectedOrFirst()
-            }
-
-            this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
-        },
-        '@keydown.up.prevent.stop'() {
-            if (! this.$data.__isOpen) {
-                this.$data.__open()
-                this.$list.activateSelectedOrLast()
-            }
-
-            this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
-        },
-        '@keydown.escape.prevent'(e) {
-            if (! this.$data.__static) e.stopPropagation()
-
-            this.$data.__close()
-            this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
-        },
     })
     })
 }
 }
 
 
 function handleLabel(el, Alpine) {
 function handleLabel(el, Alpine) {
     Alpine.bind(el, {
     Alpine.bind(el, {
         'x-ref': '__label',
         'x-ref': '__label',
-        ':id'() { return this.$id('headlessui-combobox-label') },
+        ':id'() { return this.$id('alpine-combobox-label') },
         '@click'() { this.$refs.__input.focus({ preventScroll: true }) },
         '@click'() { this.$refs.__input.focus({ preventScroll: true }) },
     })
     })
 }
 }
 
 
 function handleOptions(el, Alpine) {
 function handleOptions(el, Alpine) {
     Alpine.bind(el, {
     Alpine.bind(el, {
+        // Setup...
         'x-ref': '__options',
         'x-ref': '__options',
-        'x-init'() {
-            this.$data.__optionsEl = this.$el
-
-            queueMicrotask(() => {
-                if (Alpine.bound(this.$el, 'static')) {
-                    this.$data.__open()
-                    this.$data.__static = true;
-                }
-
-                if (Alpine.bound(this.$el, 'hold')) {
-                    this.$data.__hold = true;
-                }
-            })
+        ':id'() { return this.$id('alpine-combobox-options') },
 
 
-            // Add `role="none"` to all non option elements.
-            this.$nextTick(() => {
-                let walker = document.createTreeWalker(
-                    this.$el,
-                    NodeFilter.SHOW_ELEMENT,
-                    { acceptNode: node => {
-                        if (node.getAttribute('role') === 'option') return NodeFilter.FILTER_REJECT
-                        if (node.hasAttribute('role')) return NodeFilter.FILTER_SKIP
-                        return NodeFilter.FILTER_ACCEPT
-                    }},
-                    false
-                )
-
-                while (walker.nextNode()) walker.currentNode.setAttribute('role', 'none')
-            })
-        },
+        // Accessibility attributes...
         'role': 'listbox',
         'role': 'listbox',
-        ':id'() { return this.$id('headlessui-combobox-options') },
-        ':aria-labelledby'() { return this.$id('headlessui-combobox-button') },
-        ':aria-activedescendant'() { return this.$list.activeEl ? this.$list.activeEl.id : null },
-        'x-show'() { return this.$data.__isOpen },
-    })
-}
+        ':aria-labelledby'() { return this.$refs.__label ? this.$refs.__label.id : (this.$refs.__button ? this.$refs.__button.id : null) },
 
 
-function handleOption(el, Alpine, directive, evaluate) {
-    let value = evaluate(directive.expression)
+        // Initialize...
+        'x-init'() {
+            this.$data.__isStatic = Alpine.bound(this.$el, 'static', false)
 
 
-    Alpine.bind(el, {
-        'role': 'option',
-        'x-item'() { return value },
-        ':id'() { return this.$id('headlessui-combobox-option') },
-        ':tabindex'() { return this.$item.disabled ? undefined : '-1' },
-        ':aria-selected'() { return this.$item.selected },
-        ':aria-disabled'() { return this.$item.disabled },
-        '@click'(e) {
-            if (this.$item.disabled) e.preventDefault()
-            this.$item.select()
-            this.$data.__close()
-            this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
-        },
-        '@focus'() {
-            if (this.$item.disabled) return this.$list.deactivate()
-            this.$item.activate()
-        },
-        '@pointermove'() {
-            if (this.$item.disabled || this.$item.active) return
-            this.$item.activate()
-        },
-        '@mousemove'() {
-            if (this.$item.disabled || this.$item.active) return
-            this.$item.activate()
-        },
-        '@pointerleave'() {
-            if (this.$item.disabled || ! this.$item.active || this.$data.__hold) return
-            this.$list.deactivate()
-        },
-        '@mouseleave'() {
-            if (this.$item.disabled || ! this.$item.active || this.$data.__hold) return
-            this.$list.deactivate()
+            if (Alpine.bound(this.$el, 'hold')) {
+                this.$data.__hold = true;
+            }
         },
         },
+
+        'x-show'() { return this.$data.__isStatic ? true : this.$data.__isOpen },
     })
     })
 }
 }
 
 
-function registerListStuff(Alpine) {
-    Alpine.directive('list', (el, { expression, modifiers }, { evaluateLater, effect }) => {
-        let wrap = modifiers.includes('wrap')
-        let getOuterValue = () => null
-        let setOuterValue = () => {}
-
-        if (expression) {
-            let func = evaluateLater(expression)
-            getOuterValue = () => { let result; func(i => result = i); return result; }
-            let evaluateOuterSet = evaluateLater(`${expression} = __placeholder`)
-            setOuterValue = val => evaluateOuterSet(() => {}, { scope: { '__placeholder': val }})
-        }
-
-        let listEl = el
-
-        el._x_listState = {
-            wrap,
-            reactive: Alpine.reactive({
-                active: null,
-                selected: null,
-            }),
-            get active() { return this.reactive.active },
-            get selected() { return this.reactive.selected },
-            get activeEl() {
-                this.reactive.active
-
-                let item = this.items.find(i => i.value === this.reactive.active)
-
-                return item && item.el
-            },
-            get selectedEl() {
-                let item = this.items.find(i => i.value === this.reactive.selected)
-
-                return item && item.el
-            },
-            set active(value) { this.setActive(value) },
-            set selected(value) { this.setSelected(value) },
-            setSelected(value) {
-                let item = this.items.find(i => i.value === value)
+function handleOption(el, Alpine) {
+    Alpine.bind(el, {
+        // Setup...
+        'x-id'() { return ['alpine-combobox-option'] },
+        ':id'() { return this.$id('alpine-combobox-option') },
 
 
-                if (item && item.disabled) return
+        // Accessibility attributes...
+        'role': 'option',
+        ':tabindex'() { return this.$comboboxOption.isDisabled ? undefined : '-1' },
 
 
-                this.reactive.selected = value; setOuterValue(value)
-            },
-            setActive(value) {
-                let item = this.items.find(i => i.value === value)
+        // Only the active element should have aria-selected="true"...
+        'x-effect'() {
+            this.$comboboxOption.isActive
+                ? el.setAttribute('aria-selected', true)
+                : el.removeAttribute('aria-selected')
+        },
 
 
-                if (item && item.disabled) return
+        ':aria-disabled'() { return this.$comboboxOption.isDisabled },
 
 
-                this.reactive.active = value
-            },
-            deactivate() {
-                this.reactive.active = null
-            },
-            selectActive() {
-                this.selected = this.active
-            },
-            activateSelectedOrFirst() {
-                if (this.selected) this.active = this.selected
-                else this.first()?.activate()
-            },
-            activateSelectedOrLast() {
-                if (this.selected) this.active = this.selected
-                else this.last()?.activate()
-            },
-            items: [],
-            get filteredEls() { return this.items.filter(i => ! i.disabled).map(i => i.el) },
-            addItem(el, value, disabled = false) {
-                this.items.push({ el, value, disabled })
-                this.reorderList()
-            },
-            disableItem(el) {
-                this.items.find(i => i.el === el).disabled = true
-            },
-            removeItem(el) {
-                this.items = this.items.filter(i => i.el !== el)
-                this.reorderList()
-            },
-            reorderList() {
-                this.items = this.items.slice().sort((a, z) => {
-                    if (a === null || z === null) return 0
+        // Initialize...
+        'x-data'() {
+            return {
+                init() {
+                    let key = this.$el.__optionKey = (Math.random() + 1).toString(36).substring(7)
 
 
-                    let position = a.el.compareDocumentPosition(z.el)
+                    let value = Alpine.extractProp(this.$el, 'value')
+                    let disabled = Alpine.extractProp(this.$el, 'disabled', false, false)
 
 
-                    if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
-                    if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
-                    return 0
-                })
-            },
-            handleKeyboardNavigation(e) {
-                let item
-
-                switch (e.key) {
-                    case 'Tab':
-                    case 'Backspace':
-                    case 'Delete':
-                    case 'Meta':
-                        break;
-
-                        break;
-                    case ['ArrowDown', 'ArrowRight'][0]: // @todo handle orientation switching.
-                        e.preventDefault(); e.stopPropagation()
-                        item = this.active ? this.next() : this.first()
-                        break;
-
-                    case ['ArrowUp', 'ArrowLeft'][0]:
-                        e.preventDefault(); e.stopPropagation()
-                        item = this.active ? this.prev() : this.last()
-                        break;
-                    case 'Home':
-                    case 'PageUp':
-                        e.preventDefault(); e.stopPropagation()
-                        item = this.first()
-                        break;
-
-                    case 'End':
-                    case 'PageDown':
-                        e.preventDefault(); e.stopPropagation()
-                        item = this.last()
-                        break;
-
-                    default:
-                        if (e.key.length === 1) {
-                            // item = this.search(e.key)
-                        }
-                        break;
+                    // 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.__context.unregisterItem(this.$el.__optionKey)
                 }
                 }
+            }
+        },
 
 
-                item && item.activate(({ el }) => {
-                    setTimeout(() => el.scrollIntoView({ block: 'nearest' }))
-                })
-            },
-            // Todo: the debounce doesn't work.
-            searchQuery: '',
-            clearSearch: Alpine.debounce(function () { this.searchQuery = '' }, 350),
-            search(key) {
-                this.searchQuery += key
-
-                let el = this.filteredEls.find(el => {
-                    return el.textContent.trim().toLowerCase().startsWith(this.searchQuery)
-                })
-
-                let obj = el ? generateItemObject(listEl, el) : null
-
-                this.clearSearch()
-
-                return obj
-            },
-            first() {
-                let el = this.filteredEls[0]
-
-                return el && generateItemObject(listEl, el)
-            },
-            last() {
-                let el = this.filteredEls[this.filteredEls.length-1]
-
-                return el && generateItemObject(listEl, el)
-            },
-            next() {
-                let current = this.activeEl || this.filteredEls[0]
-                let index = this.filteredEls.indexOf(current)
-
-                let el = this.wrap
-                    ? this.filteredEls[index + 1] || this.filteredEls[0]
-                    : this.filteredEls[index + 1] || this.filteredEls[index]
-
-                return el && generateItemObject(listEl, el)
-            },
-            prev() {
-                let current = this.activeEl || this.filteredEls[0]
-                let index = this.filteredEls.indexOf(current)
-
-                let el = this.wrap
-                    ? (index - 1 < 0 ? this.filteredEls[this.filteredEls.length-1] : this.filteredEls[index - 1])
-                    : (index - 1 < 0 ? this.filteredEls[0] : this.filteredEls[index - 1])
-
-                return el && generateItemObject(listEl, el)
-            },
-        }
-
-        effect(() => {
-            el._x_listState.setSelected(getOuterValue())
-        })
-    })
-
-    Alpine.magic('list', (el) => {
-        let listEl = Alpine.findClosest(el, el => el._x_listState)
-
-        return listEl._x_listState
-    })
-
-    Alpine.directive('item', (el, { expression }, { effect, evaluate, cleanup }) => {
-        let value
-        el._x_listItem = true
-
-        if (expression) value = evaluate(expression)
-
-        let listEl = Alpine.findClosest(el, el => el._x_listState)
-
-        console.log(value)
-        listEl._x_listState.addItem(el, value)
+        // Register listeners...
+        '@click'() {
+            if (this.$comboboxOption.isDisabled) return;
 
 
-        queueMicrotask(() => {
-            Alpine.bound(el, 'disabled') && listEl._x_listState.disableItem(el)
-        })
+            this.__selectOption(this.$el)
 
 
-        cleanup(() => {
-            listEl._x_listState.removeItem(el)
-            delete el._x_listItem
-        })
-    })
+            if (! this.__isMultiple) {
+                this.__close()
+                this.__resetInput()
+            }
 
 
-    Alpine.magic('item', el => {
-        let listEl = Alpine.findClosest(el, el => el._x_listState)
-        let itemEl = Alpine.findClosest(el, el => el._x_listItem)
+            this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
+        },
+        '@mouseenter'(e) {
+            this.__context.activateEl(this.$el)
+        },
+        '@mousemove'(e) {
+            if (this.__context.isActiveEl(this.$el)) return
 
 
-        if (! listEl) throw 'Cant find x-list element'
-        if (! itemEl) throw 'Cant find x-item element'
+            this.__context.activateEl(this.$el)
+        },
+        '@mouseleave'(e) {
+            if (this.__hold) return
 
 
-        return generateItemObject(listEl, itemEl)
+            this.__context.deactivate()
+        },
     })
     })
+}
 
 
-    function generateItemObject(listEl, el) {
-        let state = listEl._x_listState
-        let item = listEl._x_listState.items.find(i => i.el === el)
-
-        return {
-            activate(callback = () => {}) {
-                state.setActive(item.value)
-
-                callback(item)
-            },
-            deactivate() {
-                if (Alpine.raw(state.active) === Alpine.raw(item.value)) state.setActive(null)
-            },
-            select(callback = () => {}) {
-                state.setSelected(item.value)
-
-                callback(item)
-            },
-            isFirst() {
-                return state.items.findIndex(i => i.el.isSameNode(el)) === 0
-            },
-            get active() {
-                if (state.reactive.active) return state.reactive.active === item.value
 
 
-                return null
-            },
-            get selected() {
-                if (state.reactive.selected) return state.reactive.selected === item.value
-
-                return null
-            },
-            get disabled() {
-                return item.disabled
-            },
-            get el() { return item.el },
-            get value() { return item.value },
-        }
-    }
+// Little utility to defer a callback into the microtask queue...
+function microtask(callback) {
+    return new Promise(resolve => queueMicrotask(() => resolve(callback())))
 }
 }
-
-/* <div x-data="{
-    query: '',
-    selected: null,
-    people: [
-        { id: 1, name: 'Kevin' },
-        { id: 2, name: 'Caleb' },
-    ],
-    get filteredPeople() {
-        return this.people.filter(i => {
-            return i.name.toLowerCase().includes(this.query.toLowerCase())
-        })
-    }
-}">
-<p x-text="query"></p>
-<div class="fixed top-16 w-72">
-    <div x-combobox x-model="selected">
-            <div class="relative mt-1">
-                <div class="relative w-full cursor-default overflow-hidden rounded-lg bg-white text-left shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-teal-300 sm:text-sm">
-                    <input x-combobox:input class="w-full border-none py-2 pl-3 pr-10 text-sm leading-5 text-gray-900 focus:ring-0" :display-value="() => (person) => person.name" @change="query = $event.target.value" />
-                    <button x-combobox:button class="absolute inset-y-0 right-0 flex items-center pr-2">
-                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="h-5 w-5 text-gray-400"><path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
-                    </button>
-                </div>
-                <ul x-combobox:options class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
-                    <div x-show="filteredPeople.length === 0 && query !== ''" class="relative cursor-default select-none py-2 px-4 text-gray-700">
-                        Nothing found.
-                    </div>
-
-                    <template x-for="person in filteredPeople" :key="person.id">
-                        <li x-combobox:option :value="person" class="relative cursor-default select-none py-2 pl-10 pr-4" :class="{ 'bg-teal-600 text-white': $comboboxOption.active, 'text-gray-900': !$comboboxOption.active, }">
-                            <span x-text="person.name" class="block truncate" :class="{ 'font-medium': $comboboxOption.selected, 'font-normal': ! $comboboxOption.selected }"></span>
-
-                            <template x-if="$comboboxOption.selected">
-                                <span class="absolute inset-y-0 left-0 flex items-center pl-3" :class="{ 'text-white': $comboboxOption.active, 'text-teal-600': !$comboboxOption.active }">
-                                    <CheckIcon class="h-5 w-5" aria-hidden="true" />
-                                </span>
-                            </template>
-                        </li>
-                    </template>
-                </ul>
-            </div>
-        </div>
-    </div>
-</div> */

+ 2 - 0
packages/ui/src/index.js

@@ -1,3 +1,4 @@
+import combobox from './combobox'
 import dialog from './dialog'
 import dialog from './dialog'
 import disclosure from './disclosure'
 import disclosure from './disclosure'
 import listbox from './listbox'
 import listbox from './listbox'
@@ -8,6 +9,7 @@ import radio from './radio'
 import tabs from './tabs'
 import tabs from './tabs'
 
 
 export default function (Alpine) {
 export default function (Alpine) {
+    combobox(Alpine)
     dialog(Alpine)
     dialog(Alpine)
     disclosure(Alpine)
     disclosure(Alpine)
     listbox(Alpine)
     listbox(Alpine)

+ 135 - 214
packages/ui/src/list-context.js

@@ -1,72 +1,95 @@
-import Alpine from "../../alpinejs/src/alpine"
+import Alpine from "alpinejs";
 
 
-export function generateContext(multiple, orientation) {
+export function generateContext(multiple, orientation, activateSelectedOrFirst) {
     return {
     return {
         /**
         /**
          * Main state...
          * Main state...
          */
          */
-        searchableText: {},
-        disabledKeys: [],
+        items: [],
         activeKey: null,
         activeKey: null,
-        selectedKeys: [],
         orderedKeys: [],
         orderedKeys: [],
-        elsByKey: {},
-        values: {},
+        activatedByKeyPress: false,
 
 
         /**
         /**
          *  Initialization...
          *  Initialization...
          */
          */
-        initItem(el, value, disabled) {
-            let key = (Math.random() + 1).toString(36).substring(7)
+        activateSelectedOrFirst: Alpine.debounce(function () {
+            activateSelectedOrFirst(false)
+        }),
 
 
-            // Register value by key...
-            this.values[key] = value
-
-            // Associate key with element...
-            this.elsByKey[key] = el
+        registerItem(key, el, value, disabled) {
+            this.items.push({
+                key, el, value, disabled
+            })
 
 
-            // Register key for ordering...
             this.orderedKeys.push(key)
             this.orderedKeys.push(key)
 
 
-            // Register key for searching...
-            this.searchableText[key] = el.textContent.trim().toLowerCase()
+            this.reorderKeys()
+            this.activateSelectedOrFirst()
+        },
+
+        unregisterItem(key) {
+            let i = this.items.findIndex((i) => i.key === key)
+            if (i !== -1) this.items.splice(i, 1)
 
 
-            // Store whether disabled or not...
-            disabled && this.disabledKeys.push(key)
+            i = this.orderedKeys.indexOf(key)
+            if (i !== -1) this.orderedKeys.splice(i, 1)
 
 
-            return key
+            this.reorderKeys()
+            this.activateSelectedOrFirst()
         },
         },
 
 
-        destroyItem(el) {
-            let key = keyByValue(this.elsByKey, el)
+        getItemByKey(key) {
+            return this.items.find(i => i.key === key)
+        },
 
 
-            delete this.values[key]
-            delete this.elsByKey[key]
-            delete this.orderedKeys[this.orderedKeys.indexOf(key)]
-            delete this.searchableText[key]
-            delete this.disabledKeys[key]
+        getItemByValue(value) {
+            return this.items.find(i => Alpine.raw(i.value) === Alpine.raw(value))
+        },
 
 
-            this.reorderKeys()
+        getItemByEl(el) {
+            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) this.deactivateKey(this.activeKey)
+
+            return item
+        },
+
+        activateItem(item) {
+            if (! item) return
+
+            this.activateKey(item.key)
         },
         },
 
 
         /**
         /**
          * Handle elements...
          * Handle elements...
          */
          */
-         reorderKeys() {
-            // Filter out elements removed from the dom...
-            this.orderedKeys.forEach((key) => {
-                let el = this.elsByKey[key]
-
-                if (el.isConnected) return
-
-                this.destroyItem(el)
-            })
+         reorderKeys: Alpine.debounce(function () {
+            this.orderedKeys = this.items.map(i => i.key)
 
 
             this.orderedKeys = this.orderedKeys.slice().sort((a, z) => {
             this.orderedKeys = this.orderedKeys.slice().sort((a, z) => {
                 if (a === null || z === null) return 0
                 if (a === null || z === null) return 0
 
 
-                let aEl = this.elsByKey[a]
-                let zEl = this.elsByKey[z]
+                let aEl = this.items.find(i => i.key === a).el
+                let zEl = this.items.find(i => i.key === z).el
 
 
                 let position = aEl.compareDocumentPosition(zEl)
                 let position = aEl.compareDocumentPosition(zEl)
 
 
@@ -74,68 +97,50 @@ export function generateContext(multiple, orientation) {
                 if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
                 if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
                 return 0
                 return 0
             })
             })
-        },
+
+            // If there no longer is the active key in the items list, then
+            // deactivate it...
+            if (! this.orderedKeys.includes(this.activeKey)) this.deactivateKey(this.activeKey)
+        }),
 
 
         activeEl() {
         activeEl() {
             if (! this.activeKey) return
             if (! this.activeKey) return
 
 
-            return this.elsByKey[this.activeKey]
+            return this.items.find(i => i.key === this.activeKey).el
         },
         },
 
 
         isActiveEl(el) {
         isActiveEl(el) {
-            let key = keyByValue(this.elsByKey, el)
-
-            if (! key) return
+            let key = this.items.find(i => i.el === el)
 
 
             return this.activeKey === key
             return this.activeKey === key
         },
         },
 
 
         activateEl(el) {
         activateEl(el) {
-            let key = keyByValue(this.elsByKey, el)
+            let item = this.items.find(i => i.el === el)
 
 
-            if (! key) return
-
-            this.activateKey(key)
-        },
-
-        selectEl(el) {
-            let key = keyByValue(this.elsByKey, el)
-
-            if (! key) return
-
-            this.selectKey(key)
-        },
-
-        isSelectedEl(el) {
-            let key = keyByValue(this.elsByKey, el)
-
-            if (! key) return
-
-            return this.isSelected(key)
+            this.activateKey(item.key)
         },
         },
 
 
         isDisabledEl(el) {
         isDisabledEl(el) {
-            let key = keyByValue(this.elsByKey, el)
-
-            if (! key) return
-
-            return this.isDisabled(key)
+            return this.items.find(i => i.el === el).disabled
         },
         },
 
 
         get isScrollingTo() { return this.scrollingCount > 0 },
         get isScrollingTo() { return this.scrollingCount > 0 },
 
 
         scrollingCount: 0,
         scrollingCount: 0,
 
 
-        activateAndScrollToKey(key) {
+        activateAndScrollToKey(key, activatedByKeyPress) {
+            if (! this.getItemByKey(key)) return
+
             // This addresses the following problem:
             // This addresses the following problem:
             // If deactivate is hooked up to mouseleave,
             // If deactivate is hooked up to mouseleave,
             // scrolling to an element will trigger deactivation.
             // scrolling to an element will trigger deactivation.
             // This "isScrollingTo" is exposed to prevent that.
             // This "isScrollingTo" is exposed to prevent that.
             this.scrollingCount++
             this.scrollingCount++
 
 
-            this.activateKey(key)
+            this.activateKey(key, activatedByKeyPress)
 
 
-            let targetEl = this.elsByKey[key]
+            let targetEl = this.items.find(i => i.key === key).el
 
 
             targetEl.scrollIntoView({ block: 'nearest' })
             targetEl.scrollIntoView({ block: 'nearest' })
 
 
@@ -148,152 +153,47 @@ export function generateContext(multiple, orientation) {
         },
         },
 
 
         /**
         /**
-         * Handle values...
+         * Handle disabled keys...
          */
          */
-        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]
-            }
+        isDisabled(key) {
+            let item = this.items.find(i => i.key === key)
 
 
-            if (multiple) {
-                // debugger
-                let keys = []
-
-                value.forEach(i => {
-                    for (let key in this.values) {
-                        if (by(this.values[key], i)) {
-                            if (! keys.includes(key)) {
-                                keys.push(key)
-                            }
-                        }
-                    }
-                })
+            if (! item) return false
 
 
-                this.selectExclusive(keys)
-            } else {
-                for (let key in this.values) {
-                    if (value && by(this.values[key], value)) {
-                        this.selectKey(key)
-                    }
-                }
-            }
+            return item.disabled
         },
         },
 
 
-        /**
-         * Handle disabled keys...
-         */
-        isDisabled(key) { return this.disabledKeys.includes(key) },
-
         get nonDisabledOrderedKeys() {
         get nonDisabledOrderedKeys() {
             return this.orderedKeys.filter(i => ! this.isDisabled(i))
             return this.orderedKeys.filter(i => ! this.isDisabled(i))
         },
         },
 
 
         /**
         /**
-         * Handle selected keys...
+         * Handle activated keys...
          */
          */
-        selectKey(key) {
-            if (this.isDisabled(key)) return
-
-            if (multiple) {
-                this.toggleSelected(key)
-            } else {
-                this.selectOnly(key)
-            }
-        },
-
-        toggleSelected(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])) {
-                    delete this.selectedKeys[i]
-                }
-            }
-
-            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 },
         isActiveKey(key) { return this.activeKey === key },
 
 
-        get active() { return this.hasActive() && this.values[this.activeKey] },
-
-        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
             if (this.isDisabled(key)) return
 
 
             this.activeKey = key
             this.activeKey = key
+            this.activatedByKeyPress = activatedByKeyPress
+        },
+
+        deactivateKey(key) {
+            if (this.activeKey === key) {
+                this.activeKey = null
+                this.activatedByKeyPress = false
+            }
         },
         },
 
 
         deactivate() {
         deactivate() {
@@ -301,6 +201,7 @@ export function generateContext(multiple, orientation) {
             if (this.isScrollingTo) return
             if (this.isScrollingTo) return
 
 
             this.activeKey = null
             this.activeKey = null
+            this.activatedByKeyPress = false
         },
         },
 
 
         /**
         /**
@@ -337,11 +238,11 @@ export function generateContext(multiple, orientation) {
 
 
             let foundKey
             let foundKey
 
 
-            for (let key in this.searchableText) {
-                let content = this.searchableText[key]
+            for (let key in this.items) {
+                let content = this.items[key].el.textContent.trim().toLowerCase()
 
 
                 if (content.startsWith(this.searchQuery)) {
                 if (content.startsWith(this.searchQuery)) {
-                    foundKey = key
+                    foundKey = this.items[key].key
                     break;
                     break;
                 }
                 }
             }
             }
@@ -351,51 +252,71 @@ export function generateContext(multiple, orientation) {
             return foundKey
             return foundKey
         },
         },
 
 
-        activateByKeyEvent(e) {
-            this.reorderKeys()
+        activateByKeyEvent(e, searchable = false, isOpen = () => false, open = () => {}, setIsTyping) {
+            let targetKey, hasActive
 
 
-            let hasActive = this.hasActive()
+            setIsTyping(true)
 
 
-            let targetKey
+            let activatedByKeyPress = true
 
 
             switch (e.key) {
             switch (e.key) {
-                case 'Tab':
-                case 'Backspace':
-                case 'Delete':
-                case 'Meta':
-                    break;
-
-                    break;
+                // case 'Backspace':
+                // case 'Delete':
                 case ['ArrowDown', 'ArrowRight'][orientation === 'vertical' ? 0 : 1]:
                 case ['ArrowDown', 'ArrowRight'][orientation === 'vertical' ? 0 : 1]:
                     e.preventDefault(); e.stopPropagation()
                     e.preventDefault(); e.stopPropagation()
+
+                    setIsTyping(false)
+
+                    if (! isOpen()) {
+                        open()
+                        break;
+                    }
+
+                    this.reorderKeys(); hasActive = this.hasActive()
+
                     targetKey = hasActive ? this.nextKey() : this.firstKey()
                     targetKey = hasActive ? this.nextKey() : this.firstKey()
                     break;
                     break;
 
 
                 case ['ArrowUp', 'ArrowLeft'][orientation === 'vertical' ? 0 : 1]:
                 case ['ArrowUp', 'ArrowLeft'][orientation === 'vertical' ? 0 : 1]:
                     e.preventDefault(); e.stopPropagation()
                     e.preventDefault(); e.stopPropagation()
+
+                    setIsTyping(false)
+
+                    if (! isOpen()) {
+                        open()
+                        break;
+                    }
+
+                    this.reorderKeys(); hasActive = this.hasActive()
+
                     targetKey = hasActive ? this.prevKey() : this.lastKey()
                     targetKey = hasActive ? this.prevKey() : this.lastKey()
                     break;
                     break;
                 case 'Home':
                 case 'Home':
                 case 'PageUp':
                 case 'PageUp':
                     e.preventDefault(); e.stopPropagation()
                     e.preventDefault(); e.stopPropagation()
+                    setIsTyping(false)
+                    this.reorderKeys(); hasActive = this.hasActive()
                     targetKey = this.firstKey()
                     targetKey = this.firstKey()
                     break;
                     break;
 
 
                 case 'End':
                 case 'End':
                 case 'PageDown':
                 case 'PageDown':
                     e.preventDefault(); e.stopPropagation()
                     e.preventDefault(); e.stopPropagation()
+                    setIsTyping(false)
+                    this.reorderKeys(); hasActive = this.hasActive()
                     targetKey = this.lastKey()
                     targetKey = this.lastKey()
                     break;
                     break;
 
 
                 default:
                 default:
-                    if (e.key.length === 1) {
+                    activatedByKeyPress = this.activatedByKeyPress
+                    if (searchable && e.key.length === 1) {
                         targetKey = this.searchKey(e.key)
                         targetKey = this.searchKey(e.key)
                     }
                     }
                     break;
                     break;
             }
             }
 
 
             if (targetKey) {
             if (targetKey) {
-                this.activateAndScrollToKey(targetKey)
+                this.activateAndScrollToKey(targetKey, activatedByKeyPress)
             }
             }
         }
         }
     }
     }

+ 221 - 90
packages/ui/src/listbox.js

@@ -12,25 +12,35 @@ export default function (Alpine) {
     Alpine.magic('listbox', (el) => {
     Alpine.magic('listbox', (el) => {
         let data = Alpine.$data(el)
         let data = Alpine.$data(el)
 
 
-        if (! data.__ready) return {
-            isDisabled: false,
-            isOpen: false,
-            selected: null,
-            active: null,
-        }
-
         return {
         return {
+            // @todo: remove "selected" and "active" when 1.0 is tagged...
+            get selected() {
+                return data.__value
+            },
+            get active() {
+                let active = data.__context.getActiveItem()
+
+                return active && active.value
+            },
+
+            get value() {
+                return data.__value
+            },
             get isOpen() {
             get isOpen() {
                 return data.__isOpen
                 return data.__isOpen
             },
             },
             get isDisabled() {
             get isDisabled() {
                 return data.__isDisabled
                 return data.__isDisabled
             },
             },
-            get selected() {
-                return data.__value
+            get activeOption() {
+                let active = data.__context.getActiveItem()
+
+                return active && active.value
             },
             },
-            get active() {
-                return data.__context.active
+            get activeIndex() {
+                let active = data.__context.getActiveItem()
+
+                return active && active.key
             },
             },
         }
         }
     })
     })
@@ -38,29 +48,19 @@ export default function (Alpine) {
     Alpine.magic('listboxOption', (el) => {
     Alpine.magic('listboxOption', (el) => {
         let data = Alpine.$data(el)
         let data = Alpine.$data(el)
 
 
-        let stub = {
-            isDisabled: false,
-            isSelected: false,
-            isActive: false,
-        }
-
-        if (! data.__ready) return stub
-
         let optionEl = Alpine.findClosest(el, i => i.__optionKey)
         let optionEl = Alpine.findClosest(el, i => i.__optionKey)
 
 
-        if (! optionEl) return stub
-
-        let context = data.__context
+        if (! optionEl) throw 'No x-combobox:option directive found...'
 
 
         return {
         return {
             get isActive() {
             get isActive() {
-                return context.isActiveEl(optionEl)
+                return data.__context.isActiveKey(optionEl.__optionKey)
             },
             },
             get isSelected() {
             get isSelected() {
-                return context.isSelectedEl(optionEl)
+                return data.__isSelected(optionEl)
             },
             },
             get isDisabled() {
             get isDisabled() {
-                return context.isDisabledEl(optionEl)
+                return data.__context.isDisabled(optionEl.__optionKey)
             },
             },
         }
         }
     })
     })
@@ -68,10 +68,16 @@ export default function (Alpine) {
 
 
 function handleRoot(el, Alpine) {
 function handleRoot(el, Alpine) {
     Alpine.bind(el, {
     Alpine.bind(el, {
+        // Setup...
         'x-id'() { return ['alpine-listbox-button', 'alpine-listbox-options', 'alpine-listbox-label'] },
         'x-id'() { return ['alpine-listbox-button', 'alpine-listbox-options', 'alpine-listbox-label'] },
         'x-modelable': '__value',
         'x-modelable': '__value',
+
+        // Initialize...
         'x-data'() {
         'x-data'() {
             return {
             return {
+                /**
+                 * Listbox state...
+                 */
                 __ready: false,
                 __ready: false,
                 __value: null,
                 __value: null,
                 __isOpen: false,
                 __isOpen: false,
@@ -82,60 +88,60 @@ function handleRoot(el, Alpine) {
                 __compareBy: null,
                 __compareBy: null,
                 __inputName: null,
                 __inputName: null,
                 __orientation: 'vertical',
                 __orientation: 'vertical',
+                __hold: false,
+
+                /**
+                 * Comobox initialization...
+                 */
                 init() {
                 init() {
-                    this.__isMultiple = Alpine.bound(el, 'multiple', false)
-                    this.__isDisabled = Alpine.bound(el, 'disabled', false)
-                    this.__inputName = Alpine.bound(el, 'name', null)
-                    this.__compareBy = Alpine.bound(el, 'by')
-                    this.__orientation = Alpine.bound(el, 'horizontal', false) ? 'horizontal' : 'vertical'
+                    this.__isMultiple = Alpine.extractProp(el, 'multiple', false)
+                    this.__isDisabled = Alpine.extractProp(el, 'disabled', false)
+                    this.__inputName = Alpine.extractProp(el, 'name', null)
+                    this.__compareBy = Alpine.extractProp(el, 'by')
+                    this.__orientation = Alpine.extractProp(el, 'horizontal', false) ? 'horizontal' : 'vertical'
 
 
-                    this.__context = generateContext(this.__isMultiple, this.__orientation)
+                    this.__context = generateContext(this.__isMultiple, this.__orientation, () => this.$data.__activateSelectedOrFirst())
 
 
-                    let defaultValue = Alpine.bound(el, 'default-value', null)
+                    let defaultValue = Alpine.extractProp(el, 'default-value', this.__isMultiple ? [] : null)
 
 
                     this.__value = defaultValue
                     this.__value = defaultValue
 
 
-                    // We have to wait for the rest of the HTML to initialize in Alpine before
-                    // we mark this component as "ready".
+                    // We have to wait again until after the "ready" processes are finished
+                    // to settle up currently selected Values (this prevents this next bit
+                    // of code from running multiple times on startup...)
                     queueMicrotask(() => {
                     queueMicrotask(() => {
-                        this.__ready = true
-
-                        // We have to wait again until after the "ready" processes are finished
-                        // to settle up currently selected Values (this prevents this next bit
-                        // of code from running multiple times on startup...)
-                        queueMicrotask(() => {
-                            // This "fingerprint" acts as a checksum of the last-known "value"
-                            // passed into x-model. We need to track this so that we can determine
-                            // from the reactive effect if it was the value that changed externally
-                            // or an option was selected internally...
-                            let lastValueFingerprint = false
-
-                            Alpine.effect(() => {
-                                // Accessing selected keys, so a change in it always triggers this effect...
-                                this.__context.selectedKeys
-
-                                if (lastValueFingerprint === false || lastValueFingerprint !== JSON.stringify(this.__value)) {
-                                    // Here we know that the value changed externally and we can add the selection...
-                                    this.__context.selectValue(this.__value, this.__compareBy)
-                                } else {
-                                    // Here we know that an option was selected and we can change the value...
-                                    this.__value = this.__context.selectedValueOrValues()
-                                }
-
-                                // Generate the "value" checksum for comparison next time...
-                                lastValueFingerprint = JSON.stringify(this.__value)
-
-                                // Everytime the value changes, we need to re-render the hidden inputs,
-                                // if a user passed the "name" prop...
-                                this.__inputName && renderHiddenInputs(this.$el, this.__inputName, this.__value)
-                            })
+                        Alpine.effect(() => {
+                            // Everytime the value changes, we need to re-render the hidden inputs,
+                            // if a user passed the "name" prop...
+                            this.__inputName && renderHiddenInputs(this.$el, this.__inputName, this.__value)
+                        })
+
+                        // Keep the currently selected value in sync with the input value...
+                        Alpine.effect(() => {
+                            this.__resetInput()
                         })
                         })
                     })
                     })
                 },
                 },
+                __resetInput() {
+                    let input = this.$refs.__input
+                    if (! input) return
+
+                    let value = this.$data.__getCurrentValue()
+
+                    input.value = value
+                },
+                __getCurrentValue() {
+                    if (! this.$refs.__input) return ''
+                    if (! this.__value) return ''
+                    if (this.$data.__displayValue && this.__value !== undefined) return this.$data.__displayValue(this.__value)
+                    if (typeof this.__value === 'string') return this.__value
+                    return ''
+                },
                 __open() {
                 __open() {
+                    if (this.__isOpen) return
                     this.__isOpen = true
                     this.__isOpen = true
 
 
-                    this.__context.activateSelectedOrFirst()
+                    this.__activateSelectedOrFirst()
 
 
                     // Safari needs more of a "tick" for focusing after x-show for some reason.
                     // 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
                     // Probably because Alpine adds an extra tick when x-showing for @click.outside
@@ -146,8 +152,85 @@ function handleRoot(el, Alpine) {
                 __close() {
                 __close() {
                     this.__isOpen = false
                     this.__isOpen = false
 
 
+                    this.__context.deactivate()
+
                     this.$nextTick(() => this.$refs.__button.focus({ preventScroll: true }))
                     this.$nextTick(() => this.$refs.__button.focus({ preventScroll: true }))
-                }
+                },
+                __activateSelectedOrFirst(activateSelected = true) {
+                    if (! this.__isOpen) return
+
+                    if (this.__context.activeKey) {
+                        this.__context.activateAndScrollToKey(this.__context.activeKey)
+                        return
+                    }
+
+                    let firstSelectedValue
+
+                    if (this.__isMultiple) {
+                        firstSelectedValue = this.__value.find(i => {
+                            return !! this.__context.getItemByValue(i)
+                        })
+                    } else {
+                        firstSelectedValue = this.__value
+                    }
+
+                    if (activateSelected && firstSelectedValue) {
+                        let firstSelected = this.__context.getItemByValue(firstSelectedValue)
+
+                        firstSelected && this.__context.activateAndScrollToKey(firstSelected.key)
+                    } else {
+                        this.__context.activateAndScrollToKey(this.__context.firstKey())
+                    }
+                },
+                __selectActive() {
+                    let active = this.$data.__context.getActiveItem()
+                    if (active) this.__toggleSelected(active.value)
+                },
+                __selectOption(el) {
+                    let item = this.__context.getItemByEl(el)
+
+                    if (item) this.__toggleSelected(item.value)
+                },
+                __isSelected(el) {
+                    let item = this.__context.getItemByEl(el)
+
+                    if (! item) return false
+                    if (! item.value) return false
+
+                    return this.__hasSelected(item.value)
+                },
+                __toggleSelected(value) {
+                    if (! this.__isMultiple) {
+                        this.__value = value
+
+                        return
+                    }
+
+                    let index = this.__value.findIndex(j => this.__compare(j, value))
+
+                    if (index === -1) {
+                        this.__value.push(value)
+                    } else {
+                        this.__value.splice(index, 1)
+                    }
+                },
+                __hasSelected(value) {
+                    if (! this.__isMultiple) return this.__compare(this.__value, value)
+
+                    return this.__value.some(i => this.__compare(i, value))
+                },
+                __compare(a, b) {
+                    let by = this.__compareBy
+
+                    if (! by) by = (a, b) => Alpine.raw(a) === Alpine.raw(b)
+
+                    if (typeof by === 'string') {
+                        let property = by
+                        by = (a, b) => a[property] === b[property]
+                    }
+
+                    return by(a, b)
+                },
             }
             }
         },
         },
     })
     })
@@ -163,13 +246,20 @@ function handleLabel(el, Alpine) {
 
 
 function handleButton(el, Alpine) {
 function handleButton(el, Alpine) {
     Alpine.bind(el, {
     Alpine.bind(el, {
+        // Setup...
         'x-ref': '__button',
         'x-ref': '__button',
         ':id'() { return this.$id('alpine-listbox-button') },
         ':id'() { return this.$id('alpine-listbox-button') },
+
+        // Accessibility attributes...
         'aria-haspopup': 'true',
         'aria-haspopup': 'true',
         ':aria-labelledby'() { return this.$id('alpine-listbox-label') },
         ':aria-labelledby'() { return this.$id('alpine-listbox-label') },
         ':aria-expanded'() { return this.$data.__isOpen },
         ':aria-expanded'() { return this.$data.__isOpen },
         ':aria-controls'() { return this.$data.__isOpen && this.$id('alpine-listbox-options') },
         ':aria-controls'() { return this.$data.__isOpen && this.$id('alpine-listbox-options') },
+
+        // Initialize....
         'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button' },
         'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button' },
+
+        // Register listeners...
         '@click'() { this.$data.__open() },
         '@click'() { this.$data.__open() },
         '@keydown'(e) {
         '@keydown'(e) {
             if (['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
             if (['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
@@ -186,31 +276,49 @@ function handleButton(el, Alpine) {
 
 
 function handleOptions(el, Alpine) {
 function handleOptions(el, Alpine) {
     Alpine.bind(el, {
     Alpine.bind(el, {
+        // Setup...
         'x-ref': '__options',
         'x-ref': '__options',
         ':id'() { return this.$id('alpine-listbox-options') },
         ':id'() { return this.$id('alpine-listbox-options') },
-        'x-init'() {
-            this.$data.__isStatic = Alpine.bound(this.$el, 'static', false)
-        },
-        'x-show'() { return this.$data.__isStatic ? true : this.$data.__isOpen },
-        '@click.outside'() { this.$data.__close() },
-        '@keydown.escape.stop.prevent'() { this.$data.__close() },
-        tabindex: '0',
+
+        // Accessibility attributes...
         'role': 'listbox',
         'role': 'listbox',
+        tabindex: '0',
         ':aria-orientation'() {
         ':aria-orientation'() {
             return this.$data.__orientation
             return this.$data.__orientation
         },
         },
         ':aria-labelledby'() { return this.$id('alpine-listbox-button') },
         ':aria-labelledby'() { return this.$id('alpine-listbox-button') },
-        ':aria-activedescendant'() { return this.__context.activeEl() && this.__context.activeEl().id },
-        '@focus'() { this.__context.activateSelectedOrFirst() },
+        ':aria-activedescendant'() {
+            if (! this.$data.__context.hasActive()) return
+
+            let active = this.$data.__context.getActiveItem()
+
+            return active ? active.el.id : null
+        },
+
+        // Initialize...
+        'x-init'() {
+            this.$data.__isStatic = Alpine.extractProp(this.$el, 'static', false)
+
+            if (Alpine.bound(this.$el, 'hold')) {
+                this.$data.__hold = true;
+            }
+        },
+
+        'x-show'() { return this.$data.__isStatic ? true : this.$data.__isOpen },
         'x-trap'() { return this.$data.__isOpen },
         'x-trap'() { return this.$data.__isOpen },
-        '@keydown'(e) { this.__context.activateByKeyEvent(e) },
+        '@click.outside'() { this.$data.__close() },
+        '@keydown.escape.stop.prevent'() { this.$data.__close() },
+        '@focus'() { this.$data.__activateSelectedOrFirst() },
+        '@keydown'(e) {
+            queueMicrotask(() => this.$data.__context.activateByKeyEvent(e, true, () => this.$data.__isOpen, () => this.$data.__open(), () => {}))
+         },
         '@keydown.enter.stop.prevent'() {
         '@keydown.enter.stop.prevent'() {
-            this.__context.selectActive();
+            this.$data.__selectActive();
 
 
             this.$data.__isMultiple || this.$data.__close()
             this.$data.__isMultiple || this.$data.__close()
         },
         },
         '@keydown.space.stop.prevent'() {
         '@keydown.space.stop.prevent'() {
-            this.__context.selectActive();
+            this.$data.__selectActive();
 
 
             this.$data.__isMultiple || this.$data.__close()
             this.$data.__isMultiple || this.$data.__close()
         },
         },
@@ -220,25 +328,48 @@ function handleOptions(el, Alpine) {
 function handleOption(el, Alpine) {
 function handleOption(el, Alpine) {
     Alpine.bind(el, () => {
     Alpine.bind(el, () => {
         return {
         return {
+            'x-id'() { return ['alpine-listbox-option'] },
             ':id'() { return this.$id('alpine-listbox-option') },
             ':id'() { return this.$id('alpine-listbox-option') },
-            ':tabindex'() { return this.$listbox.isDisabled ? false : '-1' },
+
+            // Accessibility attributes...
             'role': 'option',
             'role': 'option',
-            'x-init'() {
-                queueMicrotask(() => {
-                    let value = Alpine.bound(el, 'value')
-                    let disabled = Alpine.bound(el, 'disabled')
+            ':tabindex'() { return this.$listboxOption.isDisabled ? false : '-1' },
+            ':aria-selected'() { return this.$listboxOption.isSelected },
+
+            // 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)
 
 
-                    el.__optionKey = this.$data.__context.initItem(el, value, disabled)
-                })
+                        this.$data.__context.registerItem(key, el, value, disabled)
+                    },
+                    destroy() {
+                        this.$data.__context.unregisterItem(this.$el.__optionKey)
+                    },
+                }
             },
             },
-            ':aria-selected'() { return this.$listboxOption.isSelected },
+
+            // Register listeners...
             '@click'() {
             '@click'() {
                 if (this.$listboxOption.isDisabled) return;
                 if (this.$listboxOption.isDisabled) return;
-                this.$data.__context.selectEl(el);
+
+                this.$data.__selectOption(el)
+
                 this.$data.__isMultiple || this.$data.__close()
                 this.$data.__isMultiple || this.$data.__close()
             },
             },
-            '@mousemove'() { this.$data.__context.activateEl(el) },
-            '@mouseleave'() { this.$data.__context.deactivate() },
+            '@mouseenter'() { this.$data.__context.activateEl(el) },
+            '@mouseleave'() {
+                this.$data.__hold || this.$data.__context.deactivate()
+            },
         }
         }
     })
     })
 }
 }
+
+// Little utility to defer a callback into the microtask queue...
+function microtask(callback) {
+    return new Promise(resolve => queueMicrotask(() => resolve(callback())))
+}

+ 3 - 3
tests/cypress/integration/directives/x-bind-style.spec.js

@@ -40,7 +40,7 @@ test('style attribute object binding with CSS variable',
         </div>
         </div>
     `,
     `,
     ({ get }) => {
     ({ get }) => {
-        get('div').should(haveAttribute('style', '--MyCSS-Variable: 0.25;'))
+        get('div').should(haveAttribute('style', '--MyCSS-Variable:0.25;'))
     }
     }
 )
 )
 
 
@@ -62,7 +62,7 @@ test('CSS custom properties are set',
         </div>
         </div>
     `,
     `,
     ({ get }) => {
     ({ get }) => {
-        get('span').should(haveAttribute('style', 'color: var(--custom-prop); --custom-prop: #f00;'))
+        get('span').should(haveAttribute('style', 'color: var(--custom-prop); --custom-prop:#f00;'))
     }
     }
 )
 )
 
 
@@ -73,6 +73,6 @@ test('existing CSS custom properties are preserved',
         </div>
         </div>
     `,
     `,
     ({ get }) => {
     ({ get }) => {
-        get('span').should(haveAttribute('style', 'color: var(--custom-prop-b); --custom-prop-a: red; --custom-prop-b: var(--custom-prop-a);'))
+        get('span').should(haveAttribute('style', 'color: var(--custom-prop-b); --custom-prop-a: red; --custom-prop-b:var(--custom-prop-a);'))
     }
     }
 )
 )

+ 15 - 0
tests/cypress/integration/directives/x-bind.spec.js

@@ -453,6 +453,21 @@ test('Can retrieve Alpine bound data with global bound method',
     }
     }
 )
 )
 
 
+test('Can extract Alpine bound data as a data prop',
+    html`
+        <div x-data="{ foo: 'bar' }">
+            <div id="1" x-data="{ init() { this.$el.textContent = Alpine.extractProp(this.$el, 'foo') }}" :foo="foo"></div>
+            <div id="2" x-data="{ init() { this.$el.textContent = Alpine.extractProp(this.$el, 'foo', null, false) }}" :foo="foo"></div>
+        </div>
+    `,
+    ({ get }) => {
+        get('#1').should(haveText('bar'))
+        get('#1').should(notHaveAttribute('foo'))
+        get('#2').should(haveText('bar'))
+        get('#2').should(haveAttribute('foo', 'bar'))
+    }
+)
+
 test('x-bind updates checked attribute and property after user interaction',
 test('x-bind updates checked attribute and property after user interaction',
     html`
     html`
         <div x-data="{ checked: true }">
         <div x-data="{ checked: true }">

+ 12 - 1
tests/cypress/integration/directives/x-model.spec.js

@@ -186,5 +186,16 @@ test('x-model with fill modifier respects number modifier',
     }
     }
 );
 );
 
 
-
+test(
+    'x-model with fill applies on checkboxes bound to array',
+    html`
+        <div x-data="{ a: ['456'] }">
+            <input type="checkbox" x-model.fill="a" value="123" checked />
+            <input type="checkbox" x-model.fill="a" value="456" />
+        </div>
+    `,
+    ({ get }) => {
+        get('[x-data]').should(haveData('a', ['123']));
+    }
+);
 
 

+ 29 - 0
tests/cypress/integration/plugins/morph.spec.js

@@ -247,6 +247,35 @@ test('can morph using a custom key function',
     },
     },
 )
 )
 
 
+test('can morph using keys with existing key to be moved up',
+    [html`
+        <ul>
+            <li key="1">foo<input></li>
+            <li key="2">bar<input></li>
+            <li key="3">baz<input></li>
+        </ul>
+    `],
+    ({ get }, reload, window, document) => {
+        let toHtml = html`
+            <ul>
+                <li key="1">foo<input></li>
+                <li key="3">baz<input></li>
+            </ul>
+        `
+
+        get('li:nth-of-type(1) input').type('foo')
+        get('li:nth-of-type(3) input').type('baz')
+
+        get('ul').then(([el]) => window.Alpine.morph(el, toHtml))
+
+        get('li').should(haveLength(2))
+        get('li:nth-of-type(1)').should(haveText('foo'))
+        get('li:nth-of-type(2)').should(haveText('baz'))
+        get('li:nth-of-type(1) input').should(haveValue('foo'))
+        get('li:nth-of-type(2) input').should(haveValue('baz'))
+    },
+)
+
 test('can morph text nodes',
 test('can morph text nodes',
     [html`<h2>Foo <br> Bar</h2>`],
     [html`<h2>Foo <br> Bar</h2>`],
     ({ get }, reload, window, document) => {
     ({ get }, reload, window, document) => {

+ 1305 - 0
tests/cypress/integration/plugins/ui/combobox.spec.js

@@ -0,0 +1,1305 @@
+import { beVisible, beHidden, haveAttribute, haveClasses, notHaveClasses, haveText, contain, notContain, html, notBeVisible, notHaveAttribute, notExist, haveFocus, test, haveValue, haveLength} from '../../../utils'
+
+test('it works with x-model',
+    [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>
+
+                <article x-text="selected?.name"></article>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('ul').should(notBeVisible())
+        get('button').click()
+        get('ul').should(beVisible())
+        get('button').click()
+        get('ul').should(notBeVisible())
+        get('button').click()
+        get('[option="2"]').click()
+        get('ul').should(notBeVisible())
+        get('input').should(haveValue('Arlene Mccoy'))
+        get('article').should(haveText('Arlene Mccoy'))
+        get('button').click()
+        get('ul').should(contain('Wade Cooper'))
+            .should(contain('Arlene Mccoy'))
+            .should(contain('Devon Webb'))
+        get('[option="3"]').click()
+        get('ul').should(notBeVisible())
+        get('input').should(haveValue('Devon Webb'))
+        get('article').should(haveText('Devon Webb'))
+        get('button').click()
+        get('ul').should(contain('Wade Cooper'))
+            .should(contain('Arlene Mccoy'))
+            .should(contain('Devon Webb'))
+        get('[option="1"]').click()
+        get('ul').should(notBeVisible())
+        get('input').should(haveValue('Wade Cooper'))
+        get('article').should(haveText('Wade Cooper'))
+    },
+)
+
+test('it works with internal state',
+    [html`
+        <div
+            x-data="{ 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
+        >
+            <label x-combobox:label>Assigned to</label>
+
+            <input x-combobox:input :display-value="(person) => person.name" type="text">
+            <button x-combobox:button x-text="$combobox.value ? $combobox.value.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>
+    `],
+    ({ get }) => {
+        get('ul').should(notBeVisible())
+        get('button')
+            .should(haveText('Select Person'))
+            .click()
+        get('ul').should(beVisible())
+        get('button').click()
+        get('ul').should(notBeVisible())
+        get('button').click()
+        get('[option="2"]').click()
+        get('ul').should(notBeVisible())
+        get('button').should(haveText('Arlene Mccoy'))
+        get('input').should(haveValue('Arlene Mccoy'))
+    },
+)
+
+test('$combobox/$comboboxOption',
+    [html`
+        <div
+            x-data="{ 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
+        >
+            <label x-combobox:label>Assigned to</label>
+
+            <input x-combobox:input :display-value="(person) => person.name" type="text">
+            <button x-combobox:button x-text="$combobox.value ? $combobox.value.name : 'Select Person'"></button>
+
+            <p x-text="$combobox.activeIndex"></p>
+            <article x-text="$combobox.activeOption?.name"></article>
+
+            <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"
+                        :class="{
+                            'selected': $comboboxOption.isSelected,
+                            'active': $comboboxOption.isActive,
+                            'disabled': $comboboxOption.isDisabled,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('article').should(haveText(''))
+        get('[option="5"]').should(haveClasses(['disabled']))
+        get('button')
+            .should(haveText('Select Person'))
+            .click()
+        get('[option="1"]').should(haveClasses(['active']))
+        get('input').type('{downarrow}')
+        get('article').should(haveText('Arlene Mccoy'))
+        get('p').should(haveText('1'))
+        get('[option="2"]').should(haveClasses(['active']))
+        get('button').should(haveText('Select Person'))
+        get('[option="2"]').click()
+        get('button').should(haveText('Arlene Mccoy'))
+        get('[option="2"]').should(haveClasses(['selected']))
+    },
+)
+
+test('"name" prop',
+    [html`
+        <div
+            x-data="{ 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
+            name="person"
+        >
+            <label x-combobox:label>Assigned to</label>
+
+            <input x-combobox:input :display-value="(person) => person.name" type="text">
+            <button x-combobox:button x-text="$combobox.value ? $combobox.value : '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.id"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $comboboxOption.isSelected,
+                            'active': $comboboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('input').should(haveAttribute('value', 'null'))
+        get('button').click()
+        get('input').should(haveAttribute('value', 'null'))
+        get('[option="2"]').click()
+        get('input').should(beHidden())
+            .should(haveAttribute('name', 'person'))
+            .should(haveAttribute('value', '2'))
+            .should(haveAttribute('type', 'hidden'))
+        get('button').click()
+        get('[option="4"]').click()
+        get('input').should(beHidden())
+            .should(haveAttribute('name', 'person'))
+            .should(haveAttribute('value', '4'))
+            .should(haveAttribute('type', 'hidden'))
+    },
+);
+
+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
+            x-data="{ 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
+            name="person"
+        >
+            <label x-combobox:label>Assigned to</label>
+
+            <input x-combobox:input :display-value="(person) => person.name" type="text">
+
+            <button x-combobox:button x-text="$combobox.value ? $combobox.value.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"
+                        :class="{
+                            'selected': $comboboxOption.isSelected,
+                            'active': $comboboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('input[name="person"]').should(haveAttribute('value', 'null'))
+        get('button').click()
+        get('[name="person[id]"]').should(notExist())
+        get('[option="2"]').click()
+        get('input[name="person"]').should(notExist())
+        get('[name="person[id]"]').should(beHidden())
+            .should(haveAttribute('value', '2'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="person[name]"]').should(beHidden())
+            .should(haveAttribute('value', 'Arlene Mccoy'))
+            .should(haveAttribute('type', 'hidden'))
+        get('button').click()
+        get('[option="4"]').click()
+        get('[name="person[id]"]').should(beHidden())
+            .should(haveAttribute('value', '4'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="person[name]"]').should(beHidden())
+            .should(haveAttribute('value', 'Tom Cook'))
+            .should(haveAttribute('type', 'hidden'))
+    },
+);
+
+test('"default-value" prop',
+    [html`
+        <div
+            x-data="{ 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
+            name="person"
+            default-value="2"
+        >
+            <label x-combobox:label>Assigned to</label>
+
+            <input x-combobox:input :display-value="(person) => person.name" type="text">
+            <button x-combobox:button x-text="$combobox.value ? $combobox.value : '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.id"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $comboboxOption.isSelected,
+                            'active': $comboboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('input[name="person"]').should(beHidden())
+            .should(haveAttribute('value', '2'))
+            .should(haveAttribute('type', 'hidden'))
+    },
+);
+
+test('"multiple" prop',
+    [html`
+        <div
+            x-data="{
+                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
+            multiple
+        >
+            <label x-combobox:label>Assigned to</label>
+
+            <input x-combobox:input :display-value="(person) => person.name" type="text">
+            <button x-combobox:button x-text="$combobox.value ? $combobox.value.join(',') : 'Select People'"></button>
+
+            <ul x-combobox:options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-combobox:option
+                        :value="person.id"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $comboboxOption.isSelected,
+                            'active': $comboboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('button').click()
+        get('[option="2"]').click()
+        get('ul').should(beVisible())
+        get('button').should(haveText('2'))
+        get('[option="4"]').click()
+        get('button').should(haveText('2,4'))
+        get('ul').should(beVisible())
+        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'))
+    },
+);
+
+test('"multiple" and "name" props together',
+    [html`
+        <div
+            x-data="{
+                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
+            multiple
+            name="people"
+        >
+            <label x-combobox:label>Assigned to</label>
+
+            <input x-combobox:input :display-value="(person) => person.name" type="text">
+            <button x-combobox:button x-text="$combobox.value ? $combobox.value.map(p => p.id).join(',') : 'Select People'"></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"
+                        :class="{
+                            'selected': $comboboxOption.isSelected,
+                            'active': $comboboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        // get('input[name="people"]').should(haveAttribute('value', 'null'))
+        get('button').click()
+        get('[name="people[0][id]"]').should(notExist())
+        get('[option="2"]').click()
+        get('ul').should(beVisible())
+        get('button').should(haveText('2'))
+        get('input[name="people"]').should(notExist())
+        get('[name="people[0][id]"]').should(beHidden())
+            .should(haveAttribute('value', '2'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="people[0][name]"]').should(beHidden())
+            .should(haveAttribute('value', 'Arlene Mccoy'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[option="4"]').click()
+        get('[name="people[0][id]"]').should(beHidden())
+            .should(haveAttribute('value', '2'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="people[0][name]"]').should(beHidden())
+            .should(haveAttribute('value', 'Arlene Mccoy'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="people[1][id]"]').should(beHidden())
+            .should(haveAttribute('value', '4'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="people[1][name]"]').should(beHidden())
+            .should(haveAttribute('value', 'Tom Cook'))
+            .should(haveAttribute('type', 'hidden'))
+        get('button').should(haveText('2,4'))
+        get('ul').should(beVisible())
+        get('[option="4"]').click()
+        get('[name="people[0][id]"]').should(beHidden())
+            .should(haveAttribute('value', '2'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="people[0][name]"]').should(beHidden())
+            .should(haveAttribute('value', 'Arlene Mccoy'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="people[1][id]"]').should(notExist())
+        get('[name="people[1][name]"]').should(notExist())
+        get('button').should(haveText('2'))
+        get('ul').should(beVisible())
+    },
+);
+
+test('keyboard controls',
+    [html`
+        <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-combobox
+            x-model="active"
+        >
+            <label x-combobox:label>Assigned to</label>
+
+            <input x-combobox:input :display-value="(person) => person.name" type="text">
+            <button x-combobox:button x-text="active ? active.name : 'Select Person'"></button>
+
+            <ul x-combobox:options options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-combobox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $comboboxOption.isSelected,
+                            'active': $comboboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('.active').should(notExist())
+        get('button').click()
+        get('[options]')
+            .should(beVisible())
+        get('input').should(haveFocus())
+        get('[option="1"]')
+            .should(haveClasses(['active']))
+        get('input')
+            .type('{downarrow}')
+        get('[option="2"]')
+            .should(haveClasses(['active']))
+        get('input')
+            .type('{downarrow}')
+        get('[option="4"]')
+            .should(haveClasses(['active']))
+        get('input')
+            .type('{uparrow}')
+        get('[option="2"]')
+            .should(haveClasses(['active']))
+        get('input')
+            .type('{home}')
+        get('[option="1"]')
+            .should(haveClasses(['active']))
+        get('input')
+            .type('{end}')
+        get('[option="10"]')
+            .should(haveClasses(['active']))
+        get('input')
+            .type('{pageUp}')
+        get('[option="1"]')
+            .should(haveClasses(['active']))
+        get('input')
+            .type('{pageDown}')
+        get('[option="10"]')
+            .should(haveClasses(['active']))
+        get('input')
+            .tab()
+            .should(haveFocus())
+        get('[options]')
+            .should(beVisible())
+        get('input')
+            .type('{esc}')
+        get('[options]')
+            .should(notBeVisible())
+    },
+)
+
+test('changing value manually changes internal state',
+    [html`
+        <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-combobox
+            x-model="active"
+        >
+            <label x-combobox:label>Assigned to</label>
+
+            <input x-combobox:input :display-value="(person) => person.name" type="text">
+            <button toggle x-combobox:button x-text="$combobox.value ? $combobox.value : 'Select Person'"></button>
+
+            <button select-tim @click="active = 4">Select Tim</button>
+
+            <ul x-combobox:options options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-combobox:option
+                        :value="person.id"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $comboboxOption.isSelected,
+                            'active': $comboboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('[select-tim]').click()
+        get('[option="4"]').should(haveClasses(['selected']))
+        get('[option="1"]').should(notHaveClasses(['selected']))
+        get('[toggle]').should(haveText('4'))
+    },
+)
+
+test('has accessibility attributes',
+    [html`
+        <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-combobox
+            x-model="active"
+        >
+            <label x-combobox:label>Assigned to</label>
+
+            <input x-combobox:input :display-value="(person) => person.name" type="text">
+            <button x-combobox:button x-text="active ? active.name : 'Select Person'"></button>
+
+            <ul x-combobox:options options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-combobox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $comboboxOption.isSelected,
+                            'active': $comboboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </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', 'listbox'))
+            .should(haveAttribute('id', 'alpine-combobox-options-1'))
+            .should(haveAttribute('aria-labelledby', 'alpine-combobox-label-1'))
+
+        get('[option="1"]')
+            .should(haveAttribute('role', 'option'))
+            .should(haveAttribute('id', 'alpine-combobox-option-1'))
+            .should(haveAttribute('tabindex', '-1'))
+            .should(haveAttribute('aria-selected', 'true'))
+
+        get('[option="2"]')
+            .should(haveAttribute('role', 'option'))
+            .should(haveAttribute('id', 'alpine-combobox-option-2'))
+            .should(haveAttribute('tabindex', '-1'))
+            .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"]')
+            .should(haveAttribute('aria-selected', 'true'))
+    },
+)
+
+test('"static" prop',
+    [html`
+        <div
+            x-data="{ active: null, show: false, 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" type="text">
+            <button normal-toggle x-combobox:button x-text="active ? active.name : 'Select Person'"></button>
+
+            <button real-toggle @click="show = ! show">Toggle</button>
+
+            <ul x-combobox:options x-show="show" static>
+                <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>
+    `],
+    ({ get }) => {
+        get('ul').should(notBeVisible())
+        get('[normal-toggle]')
+            .should(haveText('Select Person'))
+            .click()
+        get('ul').should(notBeVisible())
+        get('[real-toggle]').click()
+        get('ul').should(beVisible())
+        get('[option="2"]').click()
+        get('ul').should(beVisible())
+        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'))
+    }
+)

+ 1 - 1
tests/cypress/integration/plugins/ui/listbox.spec.js

@@ -431,7 +431,7 @@ test('"multiple" and "name" props together',
         </div>
         </div>
     `],
     `],
     ({ get }) => {
     ({ get }) => {
-        get('input[name="people"]').should(haveAttribute('value', 'null'))
+        get('input[name="people"]').should(notExist())
         get('button').click()
         get('button').click()
         get('[name="people[0][id]"]').should(notExist())
         get('[name="people[0][id]"]').should(notExist())
         get('[option="2"]').click()
         get('[option="2"]').click()

+ 4 - 0
tests/cypress/utils.js

@@ -103,6 +103,10 @@ export let haveText = text => el => expect(el).to.have.text(text)
 
 
 export let notHaveText = text => el => expect(el).not.to.have.text(text)
 export let notHaveText = text => el => expect(el).not.to.have.text(text)
 
 
+export let contain = text => el => expect(el).to.contain(text)
+
+export let notContain = text => el => expect(el).not.to.contain(text)
+
 export let haveHtml = html => el => expect(el).to.have.html(html)
 export let haveHtml = html => el => expect(el).to.have.html(html)
 
 
 export let beChecked = () => el => expect(el).to.be.checked
 export let beChecked = () => el => expect(el).to.be.checked