소스 검색

Merge branch 'main' into livewire3

Caleb Porzio 2 년 전
부모
커밋
0043bf1f74

+ 223 - 23
index.html

@@ -1,4 +1,5 @@
 <html>
+<<<<<<< HEAD
     <!-- <script src="./packages/intersect/dist/cdn.js" defer></script>
     <script src="./packages/morph/dist/cdn.js" defer></script>
     <script src="./packages/persist/dist/cdn.js"></script>
@@ -118,33 +119,232 @@
     </div>
 
     <!-- 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>
+
 
-    <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>

+ 29 - 11
package-lock.json

@@ -7825,17 +7825,19 @@
             }
         },
         "packages/alpinejs": {
-            "version": "3.12.0",
+            "version": "3.12.3",
             "license": "MIT",
             "dependencies": {
                 "@vue/reactivity": "~3.1.1"
             }
         },
         "packages/collapse": {
-            "version": "3.12.0",
+            "name": "@alpinejs/collapse",
+            "version": "3.12.3",
             "license": "MIT"
         },
         "packages/csp": {
+            "name": "@alpinejs/csp",
             "version": "3.0.0-alpha.0",
             "license": "MIT",
             "dependencies": {
@@ -7843,17 +7845,21 @@
             }
         },
         "packages/docs": {
-            "version": "3.12.0-revision.1",
+            "name": "@alpinejs/docs",
+            "version": "3.12.3-revision.1",
             "license": "MIT"
         },
         "packages/focus": {
-            "version": "3.12.0",
+            "name": "@alpinejs/focus",
+            "version": "3.12.1",
             "license": "MIT",
             "dependencies": {
-                "focus-trap": "^6.6.1"
+                "focus-trap": "^6.9.4",
+                "tabbable": "^5.3.3"
             }
         },
         "packages/history": {
+            "name": "@alpinejs/history",
             "version": "3.0.0-alpha.0",
             "license": "MIT",
             "dependencies": {
@@ -7861,18 +7867,22 @@
             }
         },
         "packages/intersect": {
-            "version": "3.12.0",
+            "name": "@alpinejs/intersect",
+            "version": "3.12.3",
             "license": "MIT"
         },
         "packages/mask": {
-            "version": "3.12.0",
+            "name": "@alpinejs/mask",
+            "version": "3.12.3",
             "license": "MIT"
         },
         "packages/morph": {
-            "version": "3.12.0",
+            "name": "@alpinejs/morph",
+            "version": "3.12.3",
             "license": "MIT"
         },
         "packages/navigate": {
+            "name": "@alpinejs/navigate",
             "version": "3.10.2",
             "license": "MIT",
             "dependencies": {
@@ -7880,12 +7890,20 @@
             }
         },
         "packages/persist": {
-            "version": "3.12.0",
+            "name": "@alpinejs/persist",
+            "version": "3.12.3",
             "license": "MIT"
         },
         "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",
-    "version": "3.12.2",
+    "version": "3.12.3",
     "description": "The rugged, minimal JavaScript framework",
     "homepage": "https://alpinejs.dev",
     "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 { clone, skipDuringClone, onlyDuringClone } from './clone'
 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 { throttle } from './utils/throttle'
 import { setStyles } from './utils/styles'
@@ -45,6 +45,7 @@ let Alpine = {
     interceptInit,
     setEvaluator,
     mergeProxies,
+    extractProp,
     findClosest,
     onElRemoved,
     closestRoot,

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

@@ -6,7 +6,7 @@ import { applyBindingsObject, injectBindingProviders } from '../binds'
 
 mapAttributes(startingWith(':', into(prefix('bind:'))))
 
-directive('bind', (el, { value, modifiers, expression, original }, { effect }) => {
+let handler = (el, { value, modifiers, expression, original }, { effect }) => {
     if (! value) {
         let bindingProviders = {}
         injectBindingProviders(bindingProviders)
@@ -22,6 +22,10 @@ directive('bind', (el, { value, modifiers, expression, original }, { effect }) =
 
     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)
 
     effect(() => evaluate(result => {
@@ -32,7 +36,19 @@ directive('bind', (el, { value, modifiers, expression, original }, { effect }) =
 
         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) {
     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()))
     })
     
-    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
     // 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
 
-    callback()
+    let result = callback()
 
     shouldAutoEvaluateFunctions = cache
+
+    return result
 }
 
 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 { setClasses } from './classes'
 import { setStyles } from './styles'
@@ -22,9 +23,9 @@ export default function bind(el, name, value, modifiers = []) {
         case 'class':
             bindClasses(el, value)
             break;
-        
+
         // '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.
         case 'selected':
         case 'checked':
@@ -151,6 +152,27 @@ export function getBinding(el, name, fallback) {
     // First let's get it out of Alpine bound data.
     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.
     let attr = el.getAttribute(name)
 

+ 1 - 1
packages/collapse/package.json

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

+ 1 - 1
packages/docs/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/docs",
-    "version": "3.12.2-revision.1",
+    "version": "3.12.3-revision.1",
     "description": "The documentation for Alpine",
     "author": "Caleb Porzio",
     "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.
 
-```
+```alpine
 <div @click.capture="console.log('I will log first')">
     <button @click="console.log('I will log second')"></button>
 </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.
 
 ```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.

+ 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>
 ### 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
 <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",
-    "version": "3.12.2",
+    "version": "3.12.3",
     "description": "Manage focus within a page",
     "homepage": "https://alpinejs.dev/plugins/focus",
     "repository": {
@@ -14,6 +14,7 @@
     "module": "dist/module.esm.js",
     "unpkg": "dist/cdn.min.js",
     "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",
-    "version": "3.12.2",
+    "version": "3.12.3",
     "description": "Trigger JavaScript when an element enters the viewport",
     "homepage": "https://alpinejs.dev/plugins/intersect",
     "repository": {

+ 1 - 1
packages/mask/package.json

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

+ 1 - 1
packages/morph/package.json

@@ -1,6 +1,6 @@
 {
     "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",
     "homepage": "https://alpinejs.dev/plugins/morph",
     "repository": {

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

@@ -14,12 +14,18 @@ export let dom = {
     replace(children, old, replacement) {
         let index = children.indexOf(old)
 
+        let replacementIndex = children.indexOf(old)
+
         if (index === -1) throw 'Cant find element in children'
 
         old.replaceWith(replacement)
 
         children[index] = replacement
 
+        if (replacementIndex) {
+            children.splice(replacementIndex, 1)
+        }
+
         return children
     },
     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) {
         // I think I can get rid of this for now:
-        let fromKeyDomNodeMap = {} // keyToMap(fromChildren)
+        let fromKeyDomNodeMap = keyToMap(fromChildren)
         let fromKeyHoldovers = {}
 
         let currentTo = dom.first(toChildren)

+ 1 - 1
packages/persist/package.json

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

+ 7 - 2
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/ui",
-    "version": "3.12.1-beta.0",
+    "version": "3.12.3-beta.0",
     "description": "Headless UI components for Alpine",
     "homepage": "https://alpinejs.dev/components#headless",
     "repository": {
@@ -13,5 +13,10 @@
     "main": "dist/module.cjs.js",
     "module": "dist/module.esm.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) {
     Alpine.directive('combobox', (el, directive, { evaluate }) => {
@@ -9,57 +10,250 @@ export default function (Alpine) {
         else                                         handleRoot(el, Alpine)
     }).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) {
     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',
+
+        // Initialize...
         'x-data'() {
             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() {
-                    // @todo handle disabling the entire combobox.
                     if (this.__isOpen) return
                     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() {
-                    this.syncInputValue()
+                    this.__isOpen = false
 
-                    if (this.__static) return
+                    this.__context.deactivate()
+                },
+                __activateSelectedOrFirst(activateSelected = true) {
                     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) {
             if (
                 !! ! this.$refs.__input.contains(e.target)
@@ -67,6 +261,7 @@ function handleRoot(el, Alpine) {
                 && ! this.$refs.__options.contains(e.target)
             ) {
                 this.__close()
+                this.__resetInput()
             }
         }
     })
@@ -74,54 +269,116 @@ function handleRoot(el, Alpine) {
 
 function handleInput(el, Alpine) {
     Alpine.bind(el, {
+        // Setup...
         'x-ref': '__input',
-        ':id'() { return this.$id('headlessui-combobox-input') },
+        ':id'() { return this.$id('alpine-combobox-input') },
+
+        // Accessibility attributes...
         'role': 'combobox',
         '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) },
+
+        // Initialize...
         '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) {
             if (! this.$data.__static) e.stopPropagation()
 
+            this.$data.__stopTyping()
             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) {
     Alpine.bind(el, {
+        // Setup...
         'x-ref': '__button',
-        ':id'() { return this.$id('headlessui-combobox-button') },
+        ':id'() { return this.$id('alpine-combobox-button') },
+
+        // Accessibility attributes...
         '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-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',
+
+        // Initialize....
         'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && ! this.$el.hasAttribute('type')) this.$el.type = 'button' },
+
+        // Register listeners...
         '@click'(e) {
-            if (this.$data.__disabled) return
+            if (this.$data.__isDisabled) return
             if (this.$data.__isOpen) {
                 this.$data.__close()
+                this.$data.__resetInput()
             } else {
                 e.preventDefault()
                 this.$data.__open()
@@ -129,422 +386,109 @@ function handleButton(el, Alpine) {
 
             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) {
     Alpine.bind(el, {
         'x-ref': '__label',
-        ':id'() { return this.$id('headlessui-combobox-label') },
+        ':id'() { return this.$id('alpine-combobox-label') },
         '@click'() { this.$refs.__input.focus({ preventScroll: true }) },
     })
 }
 
 function handleOptions(el, Alpine) {
     Alpine.bind(el, {
+        // Setup...
         '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',
-        ':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 disclosure from './disclosure'
 import listbox from './listbox'
@@ -8,6 +9,7 @@ import radio from './radio'
 import tabs from './tabs'
 
 export default function (Alpine) {
+    combobox(Alpine)
     dialog(Alpine)
     disclosure(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 {
         /**
          * Main state...
          */
-        searchableText: {},
-        disabledKeys: [],
+        items: [],
         activeKey: null,
-        selectedKeys: [],
         orderedKeys: [],
-        elsByKey: {},
-        values: {},
+        activatedByKeyPress: false,
 
         /**
          *  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)
 
-            // 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...
          */
-         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) => {
                 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)
 
@@ -74,68 +97,50 @@ export function generateContext(multiple, orientation) {
                 if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
                 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() {
             if (! this.activeKey) return
 
-            return this.elsByKey[this.activeKey]
+            return this.items.find(i => i.key === this.activeKey).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
         },
 
         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) {
-            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 },
 
         scrollingCount: 0,
 
-        activateAndScrollToKey(key) {
+        activateAndScrollToKey(key, activatedByKeyPress) {
+            if (! this.getItemByKey(key)) return
+
             // This addresses the following problem:
             // If deactivate is hooked up to mouseleave,
             // scrolling to an element will trigger deactivation.
             // This "isScrollingTo" is exposed to prevent that.
             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' })
 
@@ -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() {
             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 },
 
-        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
 
             this.activeKey = key
+            this.activatedByKeyPress = activatedByKeyPress
+        },
+
+        deactivateKey(key) {
+            if (this.activeKey === key) {
+                this.activeKey = null
+                this.activatedByKeyPress = false
+            }
         },
 
         deactivate() {
@@ -301,6 +201,7 @@ export function generateContext(multiple, orientation) {
             if (this.isScrollingTo) return
 
             this.activeKey = null
+            this.activatedByKeyPress = false
         },
 
         /**
@@ -337,11 +238,11 @@ export function generateContext(multiple, orientation) {
 
             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)) {
-                    foundKey = key
+                    foundKey = this.items[key].key
                     break;
                 }
             }
@@ -351,51 +252,71 @@ export function generateContext(multiple, orientation) {
             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) {
-                case 'Tab':
-                case 'Backspace':
-                case 'Delete':
-                case 'Meta':
-                    break;
-
-                    break;
+                // case 'Backspace':
+                // case 'Delete':
                 case ['ArrowDown', 'ArrowRight'][orientation === 'vertical' ? 0 : 1]:
                     e.preventDefault(); e.stopPropagation()
+
+                    setIsTyping(false)
+
+                    if (! isOpen()) {
+                        open()
+                        break;
+                    }
+
+                    this.reorderKeys(); hasActive = this.hasActive()
+
                     targetKey = hasActive ? this.nextKey() : this.firstKey()
                     break;
 
                 case ['ArrowUp', 'ArrowLeft'][orientation === 'vertical' ? 0 : 1]:
                     e.preventDefault(); e.stopPropagation()
+
+                    setIsTyping(false)
+
+                    if (! isOpen()) {
+                        open()
+                        break;
+                    }
+
+                    this.reorderKeys(); hasActive = this.hasActive()
+
                     targetKey = hasActive ? this.prevKey() : this.lastKey()
                     break;
                 case 'Home':
                 case 'PageUp':
                     e.preventDefault(); e.stopPropagation()
+                    setIsTyping(false)
+                    this.reorderKeys(); hasActive = this.hasActive()
                     targetKey = this.firstKey()
                     break;
 
                 case 'End':
                 case 'PageDown':
                     e.preventDefault(); e.stopPropagation()
+                    setIsTyping(false)
+                    this.reorderKeys(); hasActive = this.hasActive()
                     targetKey = this.lastKey()
                     break;
 
                 default:
-                    if (e.key.length === 1) {
+                    activatedByKeyPress = this.activatedByKeyPress
+                    if (searchable && e.key.length === 1) {
                         targetKey = this.searchKey(e.key)
                     }
                     break;
             }
 
             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) => {
         let data = Alpine.$data(el)
 
-        if (! data.__ready) return {
-            isDisabled: false,
-            isOpen: false,
-            selected: null,
-            active: null,
-        }
-
         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() {
                 return data.__isOpen
             },
             get 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) => {
         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)
 
-        if (! optionEl) return stub
-
-        let context = data.__context
+        if (! optionEl) throw 'No x-combobox:option directive found...'
 
         return {
             get isActive() {
-                return context.isActiveEl(optionEl)
+                return data.__context.isActiveKey(optionEl.__optionKey)
             },
             get isSelected() {
-                return context.isSelectedEl(optionEl)
+                return data.__isSelected(optionEl)
             },
             get isDisabled() {
-                return context.isDisabledEl(optionEl)
+                return data.__context.isDisabled(optionEl.__optionKey)
             },
         }
     })
@@ -68,10 +68,16 @@ export default function (Alpine) {
 
 function handleRoot(el, Alpine) {
     Alpine.bind(el, {
+        // Setup...
         'x-id'() { return ['alpine-listbox-button', 'alpine-listbox-options', 'alpine-listbox-label'] },
         'x-modelable': '__value',
+
+        // Initialize...
         'x-data'() {
             return {
+                /**
+                 * Listbox state...
+                 */
                 __ready: false,
                 __value: null,
                 __isOpen: false,
@@ -82,60 +88,60 @@ function handleRoot(el, Alpine) {
                 __compareBy: null,
                 __inputName: null,
                 __orientation: 'vertical',
+                __hold: false,
+
+                /**
+                 * Comobox initialization...
+                 */
                 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
 
-                    // 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(() => {
-                        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() {
+                    if (this.__isOpen) return
                     this.__isOpen = true
 
-                    this.__context.activateSelectedOrFirst()
+                    this.__activateSelectedOrFirst()
 
                     // 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
@@ -146,8 +152,85 @@ function handleRoot(el, Alpine) {
                 __close() {
                     this.__isOpen = false
 
+                    this.__context.deactivate()
+
                     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) {
     Alpine.bind(el, {
+        // Setup...
         'x-ref': '__button',
         ':id'() { return this.$id('alpine-listbox-button') },
+
+        // Accessibility attributes...
         'aria-haspopup': 'true',
         ':aria-labelledby'() { return this.$id('alpine-listbox-label') },
         ':aria-expanded'() { return this.$data.__isOpen },
         ':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' },
+
+        // Register listeners...
         '@click'() { this.$data.__open() },
         '@keydown'(e) {
             if (['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
@@ -186,31 +276,49 @@ function handleButton(el, Alpine) {
 
 function handleOptions(el, Alpine) {
     Alpine.bind(el, {
+        // Setup...
         'x-ref': '__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',
+        tabindex: '0',
         ':aria-orientation'() {
             return this.$data.__orientation
         },
         ':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 },
-        '@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'() {
-            this.__context.selectActive();
+            this.$data.__selectActive();
 
             this.$data.__isMultiple || this.$data.__close()
         },
         '@keydown.space.stop.prevent'() {
-            this.__context.selectActive();
+            this.$data.__selectActive();
 
             this.$data.__isMultiple || this.$data.__close()
         },
@@ -220,25 +328,48 @@ function handleOptions(el, Alpine) {
 function handleOption(el, Alpine) {
     Alpine.bind(el, () => {
         return {
+            'x-id'() { return ['alpine-listbox-option'] },
             ':id'() { return this.$id('alpine-listbox-option') },
-            ':tabindex'() { return this.$listbox.isDisabled ? false : '-1' },
+
+            // Accessibility attributes...
             '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'() {
                 if (this.$listboxOption.isDisabled) return;
-                this.$data.__context.selectEl(el);
+
+                this.$data.__selectOption(el)
+
                 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>
     `,
     ({ 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>
     `,
     ({ 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>
     `,
     ({ 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',
     html`
         <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',
     [html`<h2>Foo <br> Bar</h2>`],
     ({ 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>
     `],
     ({ get }) => {
-        get('input[name="people"]').should(haveAttribute('value', 'null'))
+        get('input[name="people"]').should(notExist())
         get('button').click()
         get('[name="people[0][id]"]').should(notExist())
         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 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 beChecked = () => el => expect(el).to.be.checked