Caleb Porzio 2 年之前
父節點
當前提交
283ee5d564

+ 72 - 22
index.html

@@ -8,8 +8,42 @@
 <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="{ active: null, people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb' },
+                { id: 4, name: 'Tom Cook' },
+                { id: 5, name: 'Tanya Fox', disabled: true },
+                { id: 6, name: 'Hellen Schmidt' },
+                { id: 7, name: 'Caroline Schultz' },
+                { id: 8, name: 'Mason Heaney' },
+                { id: 9, name: 'Claudie Smitham' },
+                { id: 10, name: 'Emil Schaefer' },
+            ]}"
+            x-combobox
+            x-model="active"
+        >
+            <label x-combobox:label>Assigned to</label>
+
+            <input x-combobox:input :display-value="person => person.name" x-text="active ? active.name : 'Select Person'" type="text">
+            <button x-combobox:button x-text="active ? active.name : 'Select Person'"></button>
+
+            <ul x-combobox:options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-combobox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
 
-<!-- <div
+<div
             x-data="{
                 selected: null,
                 query: '',
@@ -35,14 +69,13 @@
             }"
             x-combobox
             x-model="selected"
+            nullable
         >
             <span x-text="$combobox.active && $combobox.active.name"></span>
             <label x-combobox:label>Assigned to</label>
 
             <div>
-                <div>
-                </div>
-                <input x-combobox:input @change="query = $event.target.value" :display-value="() => person => person.name" type="text">
+                <input x-combobox:input @change="query = $event.target.value" :display-value="() => person => person ? person.name : ''" type="text">
                 <button x-combobox:button x-text="selected ? selected.name : 'Select Person'">Select Person</button>
             </div>
 
@@ -53,17 +86,17 @@
                         :value="person"
                         :disabled="person.disabled"
                         :class="$comboboxOption.isActive && 'foo'"
-                    >
+                        >
                         <span x-show="$comboboxOption.isSelected">-</span>
                         <span x-text="person.name"></span>
                         <span x-show="$comboboxOption.isActive">*</span>
                     </li>
                 </template>
             </ul>
-        </div> -->
+        </div>
 
 <!-- Multiple -->
-<!-- <div
+<div
             x-data="{
                 selected: [],
                 query: '',
@@ -101,7 +134,7 @@
                         <button x-text="selectedPerson.name" @click="selected = selected.filter(i => i.id !== selectedPerson.id)"></button>
                     </template>
                 </div>
-                <input x-combobox:input @change="query = $event.target.value" type="text">
+                <input x-combobox:input @change="query = $event.target.value" :display-value="() => person => person.map(i => i.name).join(', ')" type="text">
                 <button x-combobox:button x-text="selected.length ? selected.map(i => i.name).join(', ') : 'Selected Person'">Select Person</button>
             </div>
 
@@ -119,7 +152,7 @@
                     </li>
                 </template>
             </ul>
-        </div> -->
+        </div>
 
 <div
     x-data="{
@@ -137,6 +170,13 @@
             { 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()])
@@ -179,26 +219,36 @@
                         </span>
 
                         <div class="absolute mt-1 w-full rounded-md bg-white shadow-lg">
-                            <div x-combobox:options 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">
+                            <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"
                                 >
-                                    <div x-combobox:option :value="person">
-                                        <li 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>
-                                    </div>
+                                    <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>
-                            </div>
+
+                                <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>

+ 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'
@@ -44,6 +44,7 @@ let Alpine = {
     interceptInit,
     setEvaluator,
     mergeProxies,
+    extractProp,
     findClosest,
     closestRoot,
     destroyTree,

+ 28 - 10
packages/alpinejs/src/directives/x-bind.js

@@ -1,12 +1,12 @@
 import { directive, into, mapAttributes, prefix, startingWith } from '../directives'
-import { evaluateLater } from '../evaluator'
+import { dontAutoEvaluateFunctions, evaluateLater } from '../evaluator'
 import { mutateDom } from '../mutation'
 import bind from '../utils/bind'
 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,17 +22,35 @@ 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 => {
-        // If nested object key is undefined, set the default value to empty string.
-        if (result === undefined && typeof expression === 'string' && expression.match(/\./)) {
-            result = ''
-        }
+    dontAutoEvaluateFunctions(() => {
+        effect(() => evaluate(result => {
+            // If nested object key is undefined, set the default value to empty string.
+            if (result === undefined && typeof expression === 'string' && expression.match(/\./)) {
+                result = ''
+            }
+
+            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 }
+}
 
-        mutateDom(() => bind(el, value, result, modifiers))
-    }))
-})
+directive('bind', handler)
 
 function storeKeyForXFor(el, expression) {
     el._x_keyExpression = expression

+ 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 = {}) {

+ 22 - 0
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'
@@ -132,6 +133,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)
 

+ 269 - 181
packages/ui/src/combobox.js

@@ -14,17 +14,24 @@ export default function (Alpine) {
         let data = Alpine.$data(el)
 
         return {
+            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
             },
         }
     })
@@ -36,17 +43,15 @@ export default function (Alpine) {
 
         if (! optionEl) throw 'No x-combobox:option directive found...'
 
-        let context = data.__context
-
         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)
             },
         }
     })
@@ -54,10 +59,16 @@ export default function (Alpine) {
 
 function handleRoot(el, Alpine) {
     Alpine.bind(el, {
+        // Setup...
         'x-id'() { return ['alpine-combobox-button', 'alpine-combobox-options', 'alpine-combobox-label'] },
         'x-modelable': '__value',
+
+        // Initialize...
         'x-data'() {
             return {
+                /**
+                 * Combobox state...
+                 */
                 __ready: false,
                 __value: null,
                 __isOpen: false,
@@ -69,16 +80,25 @@ function handleRoot(el, Alpine) {
                 __compareBy: null,
                 __inputName: null,
                 __orientation: 'vertical',
-                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'
+                __isTyping: false,
+                __hold: false,
 
-                    this.__context = generateContext(this.__isMultiple, this.__orientation)
-
-                    let defaultValue = Alpine.bound(el, 'default-value', null)
+                /**
+                 * Comobox initialization...
+                 */
+                init() {
+                    // We have to put this in a microtask so that all the bindings
+                    // have a chance to register so we can resolve them properly...
+                    // First, let's set initial state from
+                    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.__orientation = Alpine.extractProp(el, 'horizontal', false) ? 'horizontal' : 'vertical'
+                    this.__context = generateContext(this.__isMultiple, this.__orientation, () => this.$data.__activateSelectedOrFirst())
+
+                    let defaultValue = Alpine.bound(el, 'default-value', this.__isMultiple ? [] : null)
 
                     this.__value = defaultValue
 
@@ -86,43 +106,40 @@ function handleRoot(el, Alpine) {
                     // 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)
                         })
 
+                        let nonReactiveThis = Alpine.raw(this)
+
+                        // Keep the currently selected value in sync with the input value...
                         Alpine.effect(() => {
-                            if (this.__value) {
-                                let input = this.$refs.__input
-                                if (input) input.value = this.$data.__getCurrentValue()
-                            }
+                            if (nonReactiveThis.__isTyping) return
+
+                            this.__resetInput()
                         })
                     })
                 },
+                __startTyping() {
+                    this.__isTyping = true
+                },
+                __stopTyping() {
+                    this.__isTyping = false
+                },
+                __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.$data.__displayValue) return this.$data.__displayValue(this.__value)
+                    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 ''
                 },
@@ -130,7 +147,7 @@ function handleRoot(el, Alpine) {
                     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
@@ -141,11 +158,87 @@ function handleRoot(el, Alpine) {
                 __close() {
                     this.__isOpen = false
 
-                    // I think this shouldn't be here...
-                    // this.$nextTick(() => this.$refs.__button.focus({ preventScroll: true }))
-                }
+                    this.__context.deactivate()
+                },
+                __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)
+                },
             }
         },
+
+        // Register event liseners..
         '@mousedown.window'(e) {
             if (
                 !! ! this.$refs.__input.contains(e.target)
@@ -160,62 +253,105 @@ function handleRoot(el, Alpine) {
 
 function handleInput(el, Alpine) {
     Alpine.bind(el, {
+        // Setup...
         'x-ref': '__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-labelledby'() { return this.$refs.__label ? this.$refs.__label.id : (this.$refs.__button ? this.$refs.__button.id : null) },
-        'x-init'() {
-            queueMicrotask(() => {
-                // Alpine.effect(() => {
-                //     this.$data.__disabled = Alpine.bound(this.$el, 'disabled', false)
-                // })
+        // 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 displayValueFn = Alpine.bound(this.$el, 'display-value')
-                if (displayValueFn) this.$data.__displayValue = displayValueFn
-            })
+            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'() {
+            let displayValueFn = Alpine.extractProp(this.$el, 'display-value')
+            if (displayValueFn) this.$data.__displayValue = displayValueFn
+        },
+
+        // Register listeners...
         '@input.stop'() {
             this.$data.__open(); this.$dispatch('change')
-            // setTimeout(() => this.$data.__context.reorderKeys())
         },
-        // '@change.stop'() {
-            // setTimeout(() => this.$data.__context.reorderKeys())
-        // },
+        '@blur'() { this.$data.__isTyping = false } ,
+        '@keydown'(e) {
+            queueMicrotask(() => this.$data.__context.activateByKeyEvent(e, () => this.$data.__isOpen, () => this.$data.__open(), (state) => this.$data.__isTyping = state, () => this.$data.__resetInput()))
+         },
         '@keydown.enter.prevent.stop'() {
-            this.$data.__context.selectActive();
+            this.$data.__selectActive()
+
             this.$data.__isMultiple || this.$data.__close()
+
+            this.$data.__isTyping = false
         },
-        '@keydown'(e) {
-            queueMicrotask(() => this.$data.__context.activateByKeyEvent(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.__close()
+
+            this.$data.__isTyping = false
+        },
+        '@keydown.tab'() {
+            if (this.$data.__isOpen) { this.$data.__close() }
+
+            this.$data.__stopTyping()
+            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.$data.__context.selectActive(); this.$data.__close() }},
     })
 }
 
 function handleButton(el, Alpine) {
     Alpine.bind(el, {
+        // Setup...
         'x-ref': '__button',
         ':id'() { return this.$id('alpine-combobox-button') },
+
+        // Accessibility attributes...
         'aria-haspopup': 'true',
-        // ':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 },
+        // 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.__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()
             } else {
@@ -225,28 +361,6 @@ 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 }))
-        },
     })
 }
 
@@ -260,114 +374,88 @@ function handleLabel(el, Alpine) {
 
 function handleOptions(el, Alpine) {
     Alpine.bind(el, {
+        // Setup...
         'x-ref': '__options',
+        ':id'() { return this.$id('alpine-combobox-options') },
+
+        // Accessibility attributes...
+        'role': 'combobox',
+        ':aria-labelledby'() { return this.$refs.__label ? this.$refs.__label.id : (this.$refs.__button ? this.$refs.__button.id : null) },
+        ':aria-activedescendant'() {
+            if (! this.$data.__context.hasActive()) return
+
+            let active = this.$data.__context.getActiveItem()
+
+            return active ? active.el.id : null
+        },
+
+        // Initialize...
         'x-init'() {
             this.$data.__isStatic = Alpine.bound(this.$el, 'static', false)
 
-            // if (Alpine.bound(this.$el, 'hold')) {
-            //     this.$data.__hold = true;
-            // }
-            // 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')
-            // })
+            if (Alpine.bound(this.$el, 'hold')) {
+                this.$data.__hold = true;
+            }
         },
-        'role': 'listbox',
-        ':id'() { return this.$id('alpine-combobox-options') },
-        // ':aria-labelledby'() { return this.$id('alpine-combobox-button') },
-        // ':aria-activedescendant'() { return this.$list.activeEl ? this.$list.activeEl.id : null },
+
         'x-show'() { return this.$data.__isOpen },
     })
 }
 
 function handleOption(el, Alpine) {
     Alpine.bind(el, {
+        // Setup...
+        'x-id'() { return ['alpine-combobox-option'] },
+        ':id'() { return this.$id('alpine-combobox-option') },
+
+        // Accessibility attributes...
         'role': 'option',
-        'x-init'() {
-            el._x_optionReady = Alpine.reactive({ state: false })
+        ':tabindex'() { return this.$comboboxOption.isDisabled ? undefined : '-1' },
+        ':aria-selected'() { return this.$comboboxOption.isSelected },
+        ':aria-disabled'() { return this.$comboboxOption.isDisabled },
 
-            el.__optionKey = this.$data.__context.createItem(el)
+        // Initialize...
+        'x-data'() {
+            return {
+                init() {
+                    let key = el.__optionKey = (Math.random() + 1).toString(36).substring(7)
 
-            queueMicrotask(() => {
-                let value = Alpine.bound(el, 'value')
-                let disabled = Alpine.bound(el, 'disabled')
+                    let value = Alpine.extractProp(el, 'value')
+                    let disabled = Alpine.extractProp(el, 'disabled', false, false)
 
-                this.$data.__context.updateItem(el.__optionKey, value, disabled)
+                    this.$data.__context.registerItem(key, el, value, disabled)
 
-                // @todo: make sure this is what you want...
-                el._x_forCleanup = () => {
-                    this.$data.__context.destroyItem(el)
-                }
-            })
+                    // @todo: make sure the "destroy" hook is good enough and we don't need this...
+                    // el._x_forCleanup = () => {
+                        // this.$data.__context.unregisterItem(key)
+                    // }
+                },
+                destroy() {
+                    this.$data.__context.unregisterItem(this.$el.__optionKey)
+                },
+            }
         },
-        ':id'() { return this.$id('alpine-combobox-option') },
-        // ':tabindex'() { return this.$item.disabled ? undefined : '-1' },
-        // ':aria-selected'() { return this.$item.selected },
-        // ':aria-disabled'() { return this.$item.disabled },
+
+        // Register listeners...
         '@click'() {
             if (this.$comboboxOption.isDisabled) return;
-            this.$data.__context.selectEl(el);
+
+            this.$data.__selectOption(el)
+
             this.$data.__isMultiple || this.$data.__close()
+
             this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
         },
+
         // @todo: this is a memory leak for _x_cleanups...
         '@mouseenter'() { this.$data.__context.activateEl(el) },
-        '@mouseleave'() { this.$data.__context.deactivate() },
+        '@mouseleave'() {
+            this.$data.__hold || this.$data.__context.deactivate()
+        },
     })
 }
 
-/* <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> */
+// Little utility to defer a callback into the microtask queue...
+function microtask(callback) {
+    return new Promise(resolve => queueMicrotask(() => resolve(callback())))
+}

+ 193 - 186
packages/ui/src/list-context.js

@@ -1,10 +1,11 @@
-import Alpine from "../../alpinejs/src/alpine"
 
-export function generateContext(multiple, orientation) {
+export function generateContext(multiple, orientation, activateSelectedOrFirst) {
     return {
         /**
          * Main state...
          */
+        items: [],
+
         searchableText: {},
         disabledKeys: [],
         activeKey: null,
@@ -16,70 +17,75 @@ export function generateContext(multiple, orientation) {
         /**
          *  Initialization...
          */
-        createItem(el) {
-            let key = (Math.random() + 1).toString(36).substring(7)
+        activateSelectedOrFirst: Alpine.debounce(function () {
+            activateSelectedOrFirst(false)
+        }),
 
-            // 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)
 
-            return key
+            this.searchableText[key] = el.textContent.trim().toLowerCase()
+
+            this.reorderKeys()
+            this.activateSelectedOrFirst()
         },
 
-        updateItem(key, value, disabled) {
-            // Register value by key...
-            this.values[key] = value
+        unregisterItem(key) {
+            let i = this.items.findIndex((i) => i.key === key)
+            if (i !== -1) this.items.splice(i, 1)
 
-            let el = this.elsByKey[key]
+            i = this.orderedKeys.indexOf(key)
+            if (i !== -1) this.orderedKeys.splice(i, 1)
 
-            // Register key for searching...
-            this.searchableText[key] = el.textContent.trim().toLowerCase()
+            delete this.searchableText[key]
 
-            // Store whether disabled or not...
-            disabled && this.disabledKeys.push(key)
+            this.reorderKeys()
+            this.activateSelectedOrFirst()
         },
 
-        destroyItem(el) {
-            let key = keyByValue(this.elsByKey, el)
+        getItemByKey(key) {
+            return this.items.find(i => i.key === key)
+        },
 
-            // This line makes sense to free stored values from
-            // memory, however, in a combobox, if the options change
-            // we want to preserve selected values that may not be present
-            // in the most current list. If this becomes a problem, we will
-            // need to find a way to free values from memory while preserving
-            // selected values:
-            // delete this.values[key]
+        getItemByValue(value) {
+            return this.items.find(i => Alpine.raw(i.value) === Alpine.raw(value))
+        },
 
-            delete this.elsByKey[key]
-            delete this.orderedKeys[this.orderedKeys.indexOf(key)]
-            delete this.searchableText[key]
-            delete this.disabledKeys[key]
+        getItemByEl(el) {
+            return this.items.find(i => i.el === el)
+        },
 
-            this.deactivateKey(key)
+        getActiveItem() {
+            if (! this.hasActive()) return null
 
-            this.reorderKeys()
+            let item = this.items.find(i => i.key === this.activeKey)
+
+            if (! item) return this.activeKey = null
+
+            return item
+        },
+
+        activateItem(item) {
+            if (! item) return
+
+            this.activeKey = 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)
 
@@ -87,52 +93,32 @@ 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.activeKey = null
+        }),
 
         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)
-
-            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
+            let item = this.items.find(i => i.el === el)
 
-            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 },
@@ -140,6 +126,8 @@ export function generateContext(multiple, orientation) {
         scrollingCount: 0,
 
         activateAndScrollToKey(key) {
+            if (! this.getItemByKey(key)) return
+
             // This addresses the following problem:
             // If deactivate is hooked up to mouseleave,
             // scrolling to an element will trigger deactivation.
@@ -148,7 +136,7 @@ export function generateContext(multiple, orientation) {
 
             this.activateKey(key)
 
-            let targetEl = this.elsByKey[key]
+            let targetEl = this.items.find(i => i.key === key).el
 
             targetEl.scrollIntoView({ block: 'nearest' })
 
@@ -163,58 +151,64 @@ export function generateContext(multiple, orientation) {
         /**
          * Handle values...
          */
-        selectedValueOrValues() {
-            if (multiple) {
-                return this.selectedValues()
-            } else {
-                return this.selectedValue()
-            }
-        },
-
-        selectedValues() {
-            return this.selectedKeys.map(i => this.values[i])
-        },
-
-        selectedValue() {
-            return this.selectedKeys[0] ? this.values[this.selectedKeys[0]] : null
-        },
-
-        selectValue(value, by) {
-            if (!value) value = (multiple ? [] : null)
-            if (! by) by = (a, b) => a === b
-
-            if (typeof by === 'string') {
-                let property = by
-                by = (a, b) => a[property] === b[property]
-            }
-
-            if (multiple) {
-                let keys = []
-
-                value.forEach(i => {
-                    for (let key in this.values) {
-                        if (by(this.values[key], i)) {
-                            if (! keys.includes(key)) {
-                                keys.push(key)
-                            }
-                        }
-                    }
-                })
-
-                this.selectExclusive(keys)
-            } else {
-                for (let key in this.values) {
-                    if (value && by(this.values[key], value)) {
-                        this.selectKey(key)
-                    }
-                }
-            }
-        },
+        // selectedValueOrValues() {
+        //     if (multiple) {
+        //         return this.selectedValues()
+        //     } else {
+        //         return this.selectedValue()
+        //     }
+        // },
+
+        // selectedValues() {
+        //     return this.selectedKeys.map(i => this.values[i])
+        // },
+
+        // selectedValue() {
+        //     return this.selectedKeys[0] ? this.values[this.selectedKeys[0]] : null
+        // },
+
+        // selectValue(value, by) {
+        //     if (!value) value = (multiple ? [] : null)
+        //     if (! by) by = (a, b) => a === b
+
+        //     if (typeof by === 'string') {
+        //         let property = by
+        //         by = (a, b) => a[property] === b[property]
+        //     }
+
+        //     if (multiple) {
+        //         let keys = []
+
+        //         value.forEach(i => {
+        //             for (let key in this.values) {
+        //                 if (by(this.values[key], i)) {
+        //                     if (! keys.includes(key)) {
+        //                         keys.push(key)
+        //                     }
+        //                 }
+        //             }
+        //         })
+
+        //         this.selectExclusive(keys)
+        //     } else {
+        //         for (let key in this.values) {
+        //             if (value && by(this.values[key], value)) {
+        //                 this.selectKey(key)
+        //             }
+        //         }
+        //     }
+        // },
 
         /**
          * Handle disabled keys...
          */
-        isDisabled(key) { return this.disabledKeys.includes(key) },
+        isDisabled(key) {
+            let item = this.items.find(i => i.key === key)
+
+            if (! item) return false
+
+            return item.disabled
+        },
 
         get nonDisabledOrderedKeys() {
             return this.orderedKeys.filter(i => ! this.isDisabled(i))
@@ -223,62 +217,62 @@ export function generateContext(multiple, orientation) {
         /**
          * Handle selected keys...
          */
-        selectKey(key) {
-            if (this.isDisabled(key)) return
+        // selectKey(key) {
+        //     if (this.isDisabled(key)) return
 
-            if (multiple) {
-                this.toggleSelected(key)
-            } else {
-                this.selectOnly(key)
-            }
-        },
+        //     if (multiple) {
+        //         this.toggleSelected(key)
+        //     } else {
+        //         this.selectOnly(key)
+        //     }
+        // },
 
-        toggleSelected(key) {
-            console.log(key)
-            if (this.selectedKeys.includes(key)) {
-                this.selectedKeys.splice(this.selectedKeys.indexOf(key), 1)
-            } else {
-                this.selectedKeys.push(key)
-            }
-        },
+        // toggleSelected(key) {
+        //     console.log(key)
+        //     if (this.selectedKeys.includes(key)) {
+        //         this.selectedKeys.splice(this.selectedKeys.indexOf(key), 1)
+        //     } else {
+        //         this.selectedKeys.push(key)
+        //     }
+        // },
 
-        selectOnly(key) {
-            this.selectedKeys = []
-            this.selectedKeys.push(key)
-        },
+        // selectOnly(key) {
+        //     this.selectedKeys = []
+        //     this.selectedKeys.push(key)
+        // },
 
-        selectExclusive(keys) {
-            // We can't just do this.selectedKeys = keys,
-            // because we need to preserve reactivity...
+        // selectExclusive(keys) {
+        //     // We can't just do this.selectedKeys = keys,
+        //     // because we need to preserve reactivity...
 
-            let toAdd = [...keys]
+        //     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;
-                }
+        //     for (let i = 0; i < this.selectedKeys.length; i++) {
+        //         if (keys.includes(this.selectedKeys[i])) {
+        //             delete toAdd[toAdd.indexOf(this.selectedKeys[i])]
+        //             continue;
+        //         }
 
-                if (! keys.includes(this.selectedKeys[i])) {
-                    this.selectedKeys.splice(i, 1)
-                }
-            }
+        //         if (! keys.includes(this.selectedKeys[i])) {
+        //             this.selectedKeys.splice(i, 1)
+        //         }
+        //     }
 
-            toAdd.forEach(i => {
-                this.selectedKeys.push(i)
-            })
-        },
+        //     toAdd.forEach(i => {
+        //         this.selectedKeys.push(i)
+        //     })
+        // },
 
-        selectActive(key) {
-            if (! this.activeKey) return
+        // selectActive(key) {
+        //     if (! this.activeKey) return
 
-            this.selectKey(this.activeKey)
-        },
+        //     this.selectKey(this.activeKey)
+        // },
 
-        isSelected(key) { return this.selectedKeys.includes(key) },
+        // isSelected(key) { return this.selectedKeys.includes(key) },
 
 
-        firstSelectedKey() { return this.selectedKeys[0] },
+        // firstSelectedKey() { return this.selectedKeys[0] },
 
         /**
          * Handle activated keys...
@@ -287,21 +281,20 @@ export function generateContext(multiple, orientation) {
 
         isActiveKey(key) { return this.activeKey === key },
 
-        get active() { return this.hasActive() && this.values[this.activeKey] },
 
-        activateSelectedOrFirst() {
-            let firstSelected = this.firstSelectedKey()
+        // activateSelectedOrFirst() {
+        //     let firstSelected = this.firstSelectedKey()
 
-            if (firstSelected) {
-                return this.activateKey(firstSelected)
-            }
+        //     if (firstSelected) {
+        //         return this.activateKey(firstSelected)
+        //     }
 
-            let firstKey = this.firstKey()
+        //     let firstKey = this.firstKey()
 
-            if (firstKey) {
-                this.activateKey(firstKey)
-            }
-        },
+        //     if (firstKey) {
+        //         this.activateKey(firstKey)
+        //     }
+        // },
 
         activateKey(key) {
             if (this.isDisabled(key)) return
@@ -368,34 +361,47 @@ export function generateContext(multiple, orientation) {
             return foundKey
         },
 
-        activateByKeyEvent(e) {
-            // if (e.key === 'ArrowDown') debugger
-
-
+        activateByKeyEvent(e, isOpen = () => false, open = () => {}, setIsTyping) {
             let targetKey, hasActive
 
-            switch (e.key) {
-                case 'Tab':
-                case 'Backspace':
-                case 'Delete':
-                case 'Meta':
-                    break;
+            setIsTyping(true)
 
-                    break;
+            switch (e.key) {
+                // 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;
@@ -403,6 +409,7 @@ export function generateContext(multiple, orientation) {
                 case 'End':
                 case 'PageDown':
                     e.preventDefault(); e.stopPropagation()
+                    setIsTyping(false)
                     this.reorderKeys(); hasActive = this.hasActive()
                     targetKey = this.lastKey()
                     break;

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

@@ -452,3 +452,18 @@ test('Can retrieve Alpine bound data with global bound method',
         get('#6').should(haveText('bar'))
     }
 )
+
+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'))
+    }
+)

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

@@ -1,6 +1,6 @@
 import { beVisible, beHidden, haveAttribute, haveClasses, notHaveClasses, haveText, html, notBeVisible, notHaveAttribute, notExist, haveFocus, test} from '../../../utils'
 
-test('it works with x-model',
+test.only('it works with x-model',
     [html`
         <div
             x-data="{ active: null, people: [
@@ -47,8 +47,8 @@ test('it works with x-model',
         get('ul').should(notBeVisible())
         get('button').click()
         get('[option="2"]').click()
-        get('ul').should(notBeVisible())
-        get('button').should(haveText('Arlene Mccoy'))
+        // get('ul').should(notBeVisible())
+        // get('button').should(haveText('Arlene Mccoy'))
     },
 )