Explorar o código

Refactor listbox (not totally finished and no tests)

Caleb Porzio %!s(int64=2) %!d(string=hai) anos
pai
achega
9eeab47374

+ 122 - 26
index.html

@@ -10,33 +10,129 @@
     <script src="//cdn.tailwindcss.com"></script>
     <script src="//cdn.tailwindcss.com"></script>
     <!-- <script src="https://unpkg.com/alpinejs@3.0.0/dist/cdn.min.js" defer></script> -->
     <!-- <script src="https://unpkg.com/alpinejs@3.0.0/dist/cdn.min.js" defer></script> -->
 
 
-    <!-- Play around. -->
-    <main x-data="{ active: null, access: [
-        {
-            id: 'access-1',
-            name: 'Public access',
-            description: 'This project would be available to anyone who has the link',
-            disabled: false,
-        },
-    ]}">
-        <div x-radio group x-model="active">
-            <fieldset>
-                <legend>
-                    <h2 x-radio:label>Privacy setting</h2>
-                    <p x-radio:description>Some description</p>
-                </legend>
-
-                <div>
-                    <template x-for="(item, i) in access" :key="item.id">
-                        <div :option="item.id" x-radio:option :value="item" :disabled="item.disabled">
-                            <span :label="item.id" x-radio:label x-text="item.name"></span>
-                            <span :description="item.id" x-radio:description x-text="item.description"></span>
-                        </div>
-                    </template>
+    <div
+    x-data="{ selected: undefined, 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' },
+        ]}"
+        class="flex justify-center w-screen h-full p-12 bg-gray-50"
+    >
+        <div class="w-full max-w-xs mx-auto">
+            <div x-listbox x-model="selected" class="space-y-1">
+                <label x-listbox:label class="block text-sm font-medium leading-5 text-gray-700 mb-1">
+                    Assigned to
+                </label>
+
+                <div class="relative">
+                    <span class="inline-block w-full rounded-md shadow-sm">
+                        <button x-listbox:button class="relative w-full py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
+                            <span class="block truncate" x-text="selected ? selected.name : 'Select Person'"></span>
+                            <span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
+                                <svg class="w-5 h-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" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
+                                </svg>
+                            </span>
+                        </button>
+                    </span>
+
+                    <div class="absolute w-full mt-1 bg-white rounded-md shadow-lg">
+                        <ul x-listbox:options class="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5">
+                            <template x-for="person in people" :key="person.id">
+                                <li
+                                    x-listbox:option :value="person"
+                                    class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
+                                    :disabled="person.disabled"
+                                    :class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900', person.disabled && 'bg-gray-50 text-gray-300'].filter(Boolean)"
+                                >
+                                    <span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'" x-text="person.name"></span>
+
+                                    <span
+                                        x-show="$listboxOption.isSelected"
+                                        class="absolute inset-y-0 right-0 flex items-center pr-4"
+                                        :class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
+                                    >
+                                        <svg class="w-5 h-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>
-            </fieldset>
+            </div>
         </div>
         </div>
+    </div>
+
+
+    <!-- MULTIPLE: -->
+    <div
+        x-data="{ selected: [], 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' },
+        ]}"
+        class="flex justify-center w-screen h-full p-12 bg-gray-50"
+    >
+        <div class="w-full max-w-xs mx-auto">
+            <div x-listbox x-model="selected" multiple class="space-y-1">
+                <label x-listbox:label class="block text-sm font-medium leading-5 text-gray-700 mb-1">
+                    Assigned to
+                </label>
 
 
-        <span x-text="JSON.stringify(active)"></span>
-    </main>
+                <div class="relative">
+                    <span class="inline-block w-full rounded-md shadow-sm">
+                        <button x-listbox:button class="relative w-full py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
+                            <span class="block truncate" x-text="selected.length > 0 ? selected.map(i => i.name).join(', ') : 'Select Person'"></span>
+                            <span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
+                                <svg class="w-5 h-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" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
+                                </svg>
+                            </span>
+                        </button>
+                    </span>
+
+                    <div class="absolute w-full mt-1 bg-white rounded-md shadow-lg">
+                        <ul x-listbox:options class="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5">
+                            <template x-for="person in people" :key="person.id">
+                                <li
+                                    x-listbox:option :value="person"
+                                    class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
+                                    :disabled="person.disabled"
+                                    :class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900', person.disabled && 'bg-gray-50 text-gray-300'].filter(Boolean)"
+                                    >
+                                    <span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'" x-text="person.name"></span>
+
+                                    <span
+                                        x-show="$listboxOption.isSelected"
+                                        class="absolute inset-y-0 right-0 flex items-center pr-4"
+                                        :class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
+                                    >
+                                        <svg class="w-5 h-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>
+        </div>
+    </div>
 </html>
 </html>

+ 0 - 281
packages/ui/src/helpers.js

@@ -1,281 +0,0 @@
-export default function (Alpine) {
-    // x-listbox registers
-    //   x-list registers the state object that contains the items list
-    // x-listbox:option registers
-    //   x-item registers and pushes item object onto state object
-
-
-    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)
-
-                if (item && item.disabled) return
-
-                this.reactive.selected = value; setOuterValue(value)
-            },
-            setActive(value) {
-                let item = this.items.find(i => i.value === value)
-
-                if (item && item.disabled) return
-
-                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()
-            },
-            getItem(el) {
-                return this.items.find(i => i.el === el)
-            },
-            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
-
-                    let position = a.el.compareDocumentPosition(z.el)
-
-                    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;
-                }
-
-                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 }) => {
-        el._x_listItem = true
-
-        let listEl = Alpine.findClosest(el, el => el._x_listState)
-
-        el.ready = Alpine.reactive({ state: false })
-
-        queueMicrotask(() => {
-            let value = Alpine.bound(el, 'value');
-
-            listEl._x_listState.addItem(el, value)
-
-            el.ready.state = true
-
-            Alpine.bound(el, 'disabled') && listEl._x_listState.disableItem(el)
-        })
-
-        cleanup(() => {
-            listEl._x_listState.removeItem(el)
-            delete el._x_listItem
-        })
-    })
-
-    Alpine.magic('item', el => {
-        let listEl = Alpine.findClosest(el, el => el._x_listState)
-        let itemEl = Alpine.findClosest(el, el => el._x_listItem)
-
-        if (! listEl) throw 'Cant find x-list element'
-        if (! itemEl) throw 'Cant find x-item element'
-
-        return generateItemObject(listEl, itemEl)
-    })
-
-    function generateItemObject(listEl, el) {
-        let state = listEl._x_listState
-
-        return {
-            activate(callback = () => {}) {
-                let item = listEl._x_listState.items.find(i => i.el === el)
-
-                state.setActive(item.value)
-
-                callback(item)
-            },
-            deactivate() {
-                let item = listEl._x_listState.items.find(i => i.el === el)
-
-                if (Alpine.raw(state.active) === Alpine.raw(item.value)) state.setActive(null)
-            },
-            select(callback = () => {}) {
-                let item = listEl._x_listState.items.find(i => i.el === el)
-
-                state.setSelected(item.value)
-
-                callback(item)
-            },
-            isFirst() {
-                return state.items.findIndex(i => i.el.isSameNode(el)) === 0
-            },
-            get isActive() {
-                let item = listEl._x_listState.items.find(i => i.el === el)
-
-                if (state.reactive.active) return state.reactive.active === item.value
-            },
-            get isSelected() {
-                let item = listEl._x_listState.items.find(i => i.el === el)
-
-                if (state.reactive.selected) return state.reactive.selected === item.value
-            },
-            get isDisabled() {
-                if (! el.ready.state) return false
-
-                let item = listEl._x_listState.items.find(i => i.el === el)
-
-                return item.disabled
-            },
-            get el() { return item.el },
-            get value() { return item.value },
-        }
-    }
-}

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

@@ -1,4 +1,4 @@
-import helpers from './helpers';
+// import helpers from './helpers';
 
 
 import dialog from './dialog'
 import dialog from './dialog'
 import disclosure from './disclosure'
 import disclosure from './disclosure'
@@ -10,7 +10,7 @@ import radio from './radio'
 import tabs from './tabs'
 import tabs from './tabs'
 
 
 export default function (Alpine) {
 export default function (Alpine) {
-    helpers(Alpine)
+    // helpers(Alpine)
     dialog(Alpine)
     dialog(Alpine)
     disclosure(Alpine)
     disclosure(Alpine)
     listbox(Alpine)
     listbox(Alpine)

+ 300 - 0
packages/ui/src/list-context.js

@@ -0,0 +1,300 @@
+
+export function generateContext(multiple) {
+    return {
+        /**
+         * Main state...
+         */
+        searchableText: {},
+        disabledKeys: [],
+        activeKey: null,
+        selectedKeys: [],
+        orderedKeys: [],
+        elsByKey: {},
+        values: {},
+
+        /**
+         *  Initialization...
+         */
+        initItem(el, value, disabled) {
+            let key = (Math.random() + 1).toString(36).substring(7)
+
+            // Register value by key...
+            this.values[key] = value
+
+            // Associate key with element...
+            this.elsByKey[key] = el
+
+            // Register key for ordering...
+            this.orderedKeys.push(key)
+
+            // Register key for searching...
+            this.searchableText[key] = el.textContent.trim().toLowerCase()
+
+            // Store whether disabled or not...
+            disabled && this.disabledKeys.push(key)
+
+            return key
+        },
+
+        /**
+         * Handle elements...
+         */
+        activeEl() {
+            if (! this.activeKey) return
+
+            return this.elsByKey[this.activeKey]
+        },
+
+        isActiveEl(el) {
+            let key = keyByValue(this.elsByKey, el)
+
+            if (! key) return
+
+            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
+
+            return this.isSelected(key)
+        },
+
+        isDisabledEl(el) {
+            let key = keyByValue(this.elsByKey, el)
+
+            if (! key) return
+
+            return this.isDisabled(key)
+        },
+
+        scrollToKey(key) {
+            this.elsByKey[key].scrollIntoView({ block: 'nearest' })
+        },
+
+        /**
+         * 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
+        },
+
+        /**
+         * Handle disabled keys...
+         */
+        isDisabled(key) { return this.disabledKeys.includes(key) },
+
+        get nonDisabledOrderedKeys() {
+            return this.orderedKeys.filter(i => ! this.isDisabled(i))
+        },
+
+        /**
+         * Handle selected 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)
+        },
+
+        selectActive(key) {
+            if (! this.activeKey) return
+
+            this.selectKey(this.activeKey)
+        },
+
+        isSelected(key) { return this.selectedKeys.includes(key) },
+
+
+        firstSelectedKey() { return this.selectedKeys[0] },
+
+        /**
+         * Handle activated keys...
+         */
+        hasActive() { return !! this.activeKey },
+
+        isActiveKey(key) { return this.activeKey === key },
+
+        activateSelectedOrFirst() {
+            let firstSelected = this.firstSelectedKey()
+
+            if (firstSelected) {
+                return this.activateKey(firstSelected)
+            }
+
+            let firstKey = this.firstKey()
+
+            if (firstKey) {
+                this.activateKey(firstKey)
+            }
+        },
+
+        activateKey(key) {
+            if (this.isDisabled(key)) return
+
+            this.activeKey = key
+        },
+
+        deactivate() { return this.activeKey = null },
+
+        /**
+         * Handle active key traveral...
+         */
+
+        nextKey() {
+            if (! this.activeKey) return
+
+            let index = this.nonDisabledOrderedKeys.findIndex(i => i === this.activeKey)
+
+            return this.nonDisabledOrderedKeys[index + 1]
+        },
+
+        prevKey() {
+            if (! this.activeKey) return
+
+            let index = this.nonDisabledOrderedKeys.findIndex(i => i === this.activeKey)
+
+            return this.nonDisabledOrderedKeys[index - 1]
+        },
+
+        firstKey() { return this.nonDisabledOrderedKeys[0] },
+
+        lastKey() { return this.nonDisabledOrderedKeys[this.nonDisabledOrderedKeys.length - 1] },
+
+        searchQuery: '',
+
+        clearSearch: Alpine.debounce(function () { this.searchQuery = '' }, 350),
+
+        searchKey(query) {
+            this.clearSearch()
+
+            this.searchQuery += query
+
+            let foundKey
+
+            for (let key in this.searchableText) {
+                let content = this.searchableText[key]
+
+                if (content.startsWith(this.searchQuery)) {
+                    foundKey = key
+                    break;
+                }
+            }
+
+            if (! this.nonDisabledOrderedKeys.includes(foundKey)) return
+
+            return foundKey
+        },
+
+        activateByKeyEvent(e) {
+            let hasActive = this.hasActive()
+
+            let targetKey
+
+            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()
+                    targetKey = hasActive ? this.nextKey() : this.firstKey()
+                    break;
+
+                case ['ArrowUp', 'ArrowLeft'][0]:
+                    e.preventDefault(); e.stopPropagation()
+                    targetKey = hasActive ? this.prevKey() : this.lastKey()
+                    break;
+                case 'Home':
+                case 'PageUp':
+                    e.preventDefault(); e.stopPropagation()
+                    targetKey = this.firstKey()
+                    break;
+
+                case 'End':
+                case 'PageDown':
+                    e.preventDefault(); e.stopPropagation()
+                    targetKey = this.lastKey()
+                    break;
+
+                default:
+                    if (e.key.length === 1) {
+                        targetKey = this.searchKey(e.key)
+                    }
+                    break;
+            }
+
+            if (targetKey) {
+                this.activateKey(targetKey)
+
+                setTimeout(() => this.scrollToKey(targetKey))
+            }
+        }
+    }
+}
+
+function keyByValue(object, value) {
+    return Object.keys(object).find(key => object[key] === value)
+}
+
+// reorderList() {
+//     this.items = this.items.slice().sort((a, z) => {
+//         if (a === null || z === null) return 0
+
+//         let position = a.el.compareDocumentPosition(z.el)
+
+//         if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
+//         if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
+//         return 0
+//     })
+// },
+

+ 101 - 35
packages/ui/src/listbox.js

@@ -1,7 +1,8 @@
+import { generateContext } from './lists-context'
 
 
 export default function (Alpine) {
 export default function (Alpine) {
     Alpine.directive('listbox', (el, directive) => {
     Alpine.directive('listbox', (el, directive) => {
-        if (!directive.value) handleRoot(el, Alpine)
+        if (! directive.value) handleRoot(el, Alpine)
         else if (directive.value === 'label') handleLabel(el, Alpine)
         else if (directive.value === 'label') handleLabel(el, Alpine)
         else if (directive.value === 'button') handleButton(el, Alpine)
         else if (directive.value === 'button') handleButton(el, Alpine)
         else if (directive.value === 'options') handleOptions(el, Alpine)
         else if (directive.value === 'options') handleOptions(el, Alpine)
@@ -9,27 +10,80 @@ export default function (Alpine) {
     })
     })
 
 
     Alpine.magic('listbox', (el, { evaluate }) => {
     Alpine.magic('listbox', (el, { evaluate }) => {
-        return evaluate('$list', el)
+        let data = Alpine.$data(el)
+
+        if (! data.__ready) return {
+            isDisabled: false,
+        }
+
+        return {
+            get isDisabled() {
+                return data.__isDisabled
+            },
+        }
     })
     })
 
 
     Alpine.magic('listboxOption', (el, { evaluate }) => {
     Alpine.magic('listboxOption', (el, { evaluate }) => {
-        return evaluate('$item', 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
+
+        return {
+            get isActive() {
+                return context.isActiveEl(optionEl)
+            },
+            get isSelected() {
+                return context.isSelectedEl(optionEl)
+            },
+            get isDisabled() {
+                return context.isDisabledEl(optionEl)
+            },
+        }
     })
     })
 }
 }
 
 
 function handleRoot(el, Alpine) {
 function handleRoot(el, Alpine) {
     Alpine.bind(el, {
     Alpine.bind(el, {
         'x-id'() { return ['alpine-listbox-button', 'alpine-listbox-options', 'alpine-listbox-label'] },
         'x-id'() { return ['alpine-listbox-button', 'alpine-listbox-options', 'alpine-listbox-label'] },
-        'x-list': '__value',
         'x-modelable': '__value',
         'x-modelable': '__value',
         'x-data'() {
         'x-data'() {
             return {
             return {
+                __ready: false,
                 __value: null,
                 __value: null,
                 __isOpen: false,
                 __isOpen: false,
+                __context: undefined,
+                __isMultiple: undefined,
+                __isDisabled: undefined,
+                init() {
+                    this.__isMultiple = Alpine.bound(el, 'multiple', false)
+                    this.__isDisabled = Alpine.bound(el, 'disabled', false)
+
+                    this.__context = generateContext(this.__isMultiple)
+
+                    Alpine.effect(() => {
+                        this.__value = this.__context.selectedValueOrValues()
+                    })
+
+                    setTimeout(() => {
+                        this.__ready = true
+                    })
+                },
                 __open() {
                 __open() {
                     this.__isOpen = true
                     this.__isOpen = true
 
 
-                    this.$list.activateSelectedOrFirst()
+                    this.__context.activateSelectedOrFirst()
 
 
                     // Safari needs more of a "tick" for focusing after x-show for some reason.
                     // Safari needs more of a "tick" for focusing after x-show for some reason.
                     // Probably because Alpine adds an extra tick when x-showing for @click.outside
                     // Probably because Alpine adds an extra tick when x-showing for @click.outside
@@ -65,8 +119,14 @@ function handleButton(el, Alpine) {
         ':aria-controls'() { return this.$data.__isOpen && this.$id('alpine-listbox-options') },
         ':aria-controls'() { return this.$data.__isOpen && this.$id('alpine-listbox-options') },
         'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button' },
         'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button' },
         '@click'() { this.$data.__open() },
         '@click'() { this.$data.__open() },
-        '@keydown.[down|up|space|enter].stop.prevent'() { this.$data.__open() },
-        '@keydown.up.stop.prevent'() { this.$data.__open() },
+        '@keydown'(e) {
+            if (['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
+                e.stopPropagation()
+                e.preventDefault()
+
+                this.$data.__open()
+            }
+        },
         '@keydown.space.stop.prevent'() { this.$data.__open() },
         '@keydown.space.stop.prevent'() { this.$data.__open() },
         '@keydown.enter.stop.prevent'() { this.$data.__open() },
         '@keydown.enter.stop.prevent'() { this.$data.__open() },
     })
     })
@@ -74,47 +134,53 @@ function handleButton(el, Alpine) {
 
 
 function handleOptions(el, Alpine) {
 function handleOptions(el, Alpine) {
     Alpine.bind(el, {
     Alpine.bind(el, {
-        tabindex: '0',
-        '@keydown'(e) { this.$list.handleKeyboardNavigation(e) },
-        // '@focus'() { this.$list.first().activate() },
-        '@keydown.enter.stop.prevent'() { this.$list.selectActive(); this.$data.__close() },
-        '@keydown.space.stop.prevent'() { this.$list.selectActive(); this.$data.__close() },
         'x-ref': '__options',
         'x-ref': '__options',
-        'aria-orientation': 'vertical',
-        'role': 'listbox',
         ':id'() { return this.$id('alpine-listbox-options') },
         ':id'() { return this.$id('alpine-listbox-options') },
-        // ':aria-labelledby'() { return 'listbox-button-' + this.$data.__buttonId },
-        // ':aria-activedescendant'() { return this.$data.__activeEl && this.$data.__activeEl.id },
         'x-show'() { return this.$data.__isOpen },
         'x-show'() { return this.$data.__isOpen },
-        'x-trap'() { return this.$data.__isOpen },
         '@click.outside'() { this.$data.__close() },
         '@click.outside'() { this.$data.__close() },
         '@keydown.escape.stop.prevent'() { this.$data.__close() },
         '@keydown.escape.stop.prevent'() { this.$data.__close() },
+        tabindex: '0',
+        'role': 'listbox',
+        'aria-orientation': 'vertical',
+        ':aria-labelledby'() { return this.$id('alpine-listbox-button') },
+        ':aria-activedescendant'() { return this.__context.activateEl() && this.__context.activateEl().id },
+        '@focus'() { this.__context.activateSelectedOrFirst() },
+        'x-trap'() { return this.$data.__isOpen },
+        '@keydown'(e) { this.__context.activateByKeyEvent(e) },
+        '@keydown.enter.stop.prevent'() {
+            this.__context.selectActive();
+
+            this.$data.__isMultiple || this.$data.__close()
+        },
+        '@keydown.space.stop.prevent'() {
+            this.__context.selectActive();
+
+            this.$data.__isMultiple || this.$data.__close()
+        },
     })
     })
 }
 }
 
 
 function handleOption(el, Alpine) {
 function handleOption(el, Alpine) {
     Alpine.bind(el, () => {
     Alpine.bind(el, () => {
         return {
         return {
-            'x-data'() {
-                return {
-                    '__value': undefined,
-                    '__disabled': false,
-                    init() {
-                        queueMicrotask(() => {
-                            this.__value = Alpine.bound(el, 'value');
-                            this.__disabled = Alpine.bound(el, 'disabled', false);
-                        })
-                    }
-                }
-            },
-            'x-item'() { return this.$data.__value },
             ':id'() { return this.$id('alpine-listbox-option') },
             ':id'() { return this.$id('alpine-listbox-option') },
-            ':tabindex'() { return this.$data.__disabled ? false : '-1' },
+            ':tabindex'() { return this.$listbox.isDisabled ? false : '-1' },
             'role': 'option',
             'role': 'option',
-            ':aria-selected'() { return this.$data.__selected === this.$data.__value },
-            '@click'() { this.$item.select(); this.$data.__close() },
-            '@mousemove'() { this.$item.activate() },
-            '@mouseleave'() { this.$item.deactivate() },
+            'x-init'() {
+                queueMicrotask(() => {
+                    let value = Alpine.bound(el, 'value')
+                    let disabled = Alpine.bound(el, 'disabled')
+
+                    el.__optionKey = this.$data.__context.initItem(el, value, disabled)
+                })
+            },
+            ':aria-selected'() { return this.$listboxOption.isSelected },
+            '@click'() {
+                this.$data.__context.selectEl(el);
+                this.$data.__isMultiple || this.$data.__close()
+            },
+            '@mousemove'() { this.$data.__context.activateEl(el) },
+            '@mouseleave'() { this.$data.__context.deactivate() },
         }
         }
     })
     })
 }
 }

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

@@ -101,7 +101,6 @@ test('it works with internal state',
     },
     },
 )
 )
 
 
-// @todo fix $listboxOption.isDisabled
 test('$listbox/$listboxOption',
 test('$listbox/$listboxOption',
     [html`
     [html`
         <div
         <div
@@ -269,7 +268,7 @@ test('"default-value" prop',
 );
 );
 
 
 // @todo support "multiple" prop
 // @todo support "multiple" prop
-test('"multiple" prop',
+test.only('"multiple" prop',
     [html`
     [html`
         <div
         <div
             x-data="{
             x-data="{
@@ -327,7 +326,6 @@ test('"multiple" prop',
 );
 );
 
 
 // @todo support "static" prop
 // @todo support "static" prop
-
 test('keyboard controls',
 test('keyboard controls',
     [html`
     [html`
         <div
         <div
@@ -550,3 +548,9 @@ test('has accessibility attributes',
             .should(haveAttribute('aria-activedescendant', 'alpine-listbox-item-2'))
             .should(haveAttribute('aria-activedescendant', 'alpine-listbox-item-2'))
     },
     },
 )
 )
+
+
+// Supporting multiple
+// Supporting native inputs
+// Static open/closed
+// Accessibility