Caleb Porzio 3 years ago
parent
commit
7b3d1062c2

+ 14 - 51
index.html

@@ -1,56 +1,19 @@
 <html>
-<script src="./packages/intersect/dist/cdn.js" defer></script>
-<script src="./packages/morph/dist/cdn.js" defer></script>
-<script src="./packages/history/dist/cdn.js"></script>
-<script src="./packages/persist/dist/cdn.js"></script>
-<script src="./packages/focus/dist/cdn.js"></script>
-<script src="./packages/ui/dist/cdn.js"></script>
-<script src="./packages/alpinejs/dist/cdn.js" defer></script>
-<!-- <script src="https://unpkg.com/alpinejs@3.0.0/dist/cdn.min.js" defer></script> -->
+    <script src="./packages/intersect/dist/cdn.js" defer></script>
+    <script src="./packages/morph/dist/cdn.js" defer></script>
+    <script src="./packages/history/dist/cdn.js"></script>
+    <script src="./packages/persist/dist/cdn.js"></script>
+    <script src="./packages/focus/dist/cdn.js"></script>
+    <script src="./packages/mask/dist/cdn.js"></script>
+    <script src="./packages/alpinejs/dist/cdn.js" defer></script>
+    <!-- <script src="https://unpkg.com/alpinejs@3.0.0/dist/cdn.min.js" defer></script> -->
 
-<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())
-        })
-    }
-}">
-    <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 type="text" class="w-full border-none py-2 pl-3 pr-10 text-sm leading-5 text-gray-900 focus:ring-0" x-model="query">
-                    <!-- <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" xtodo-displayValue="(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>
+    <!-- Play around. -->
+    <div x-data="{ open: false }">
+        <button @click="open = !open">Toggle</button>
 
-                    <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>
+        <span x-show="open">
+            Content...
+        </span>
     </div>
-</div>
-
 </html>

+ 0 - 1
packages/alpinejs/src/directives.js

@@ -180,7 +180,6 @@ let directiveOrder = [
     'if',
     DEFAULT,
     'teleport',
-    'element',
 ]
 
 function byPriority(a, b) {

+ 0 - 94
packages/docs/src/en/ui/dialog.md

@@ -1,94 +0,0 @@
----
-order: 1
-title: Dialog
-description: ...
-graph_image: https://alpinejs.dev/social_modal.jpg
----
-
-# Dialog (Modal)
-
-Building a modal with Alpine might appear as simple as putting `x-show` on an element styled as a modal. Unfortunately, much more goes into building a robust, accessible modal. The following functionality is considered essential:
-
-* Close the modal on escape
-* Close when you click outside the modal onto the background overlay
-* Trap focus within the modal to prevent focusing the page behind it
-* Disable scrolling the background when modal is active
-* Proper accessibility HTML attributes such as `role="dialog"`
-
-For these cases, the `x-dialog` family of directives exists. Take a look:
-
-## A Basic Example
-
-```alpine
-<div x-data="{ open: false }">
-    <button @click="open = true">Open Modal</button>
-
-    <div x-dialog x-model="open">
-        <div x-dialog:overlay></div>
-
-        <div x-dialog:panel>
-            Some modal
-
-            <button @click="$dialog.close()">Close</button>
-        </div>
-    </div>
-</div>
-```
-
-<!-- START_VERBATIM -->
-<div class="demo">
-<div x-data="{ open: false }">
-    <button @click="open = true">Open Modal</button>
-
-    <div x-dialog x-model="open" class="relative z-50" style="display: none;">
-        <div x-dialog:overlay x-transition.opacity class="fixed inset-0 bg-black bg-opacity-25"></div>
-
-        <div class="fixed inset-0 overflow-y-auto">
-            <div class="flex min-h-full items-center justify-center p-4 text-center">
-                <div x-dialog:panel x-transition class="w-full max-w-md transform overflow-hidden rounded bg-white p-6 text-left align-middle shadow-xl transition-all">
-                    <h2 x-dialog:title class="text-lg">Your Title</h2>
-
-                    <p x-dialog:description class="pt-2">Your description.</p>
-
-                    <button @click="$dialog.close()">Close</button>
-                </div>
-            </div>
-        </div>
-    </div>
-</div>
-</div>
-<!-- END_VERBATIM -->
-
-## Directives
-
-* x-dialog
-* x-dialog:overlay
-* x-dialog:panel
-* x-dialog:title
-* x-dialog:description
-
-## Magics
-
-* $dialog
-
-## Props
-
-* open
-* @close
-* static
-* initial-focus
-
-## Adding an overlay
-
-## Specifying initial focus
-
-## Controlling state without x-model
-
-## Statically controlling
-
-## Adding a title and description
-
-## Accessibility Notes
-
-## Keyboard Shortcuts
-

+ 0 - 71
packages/docs/src/en/ui/popover.md

@@ -1,71 +0,0 @@
----
-order: 2
-title: Popover
-description: ...
-graph_image: https://alpinejs.dev/social_popover.jpg
----
-
-# Popover (Dropdown)
-
-Building a popover with Alpine might appear as simple as putting `x-show` on an element styled as a dropdown. Unfortunately, much more goes into building a robust, accessible dropdown such as:
-
-* Close on escape
-* Close when you click outside the dropdown
-* Close the dropdown when focus leaves it
-* Disable scrolling the background when modal is active
-* Support tabbing between popovers in a group
-* Adding proper ARIA attributes
-
-...
-
-## A Basic Example
-
-```alpine
-<div x-popover>
-    <button x-popover:button>Open Dropdown</button>
-
-    <div x-popover:panel>
-        <a href="#">Link #1</a>
-        <a href="#">Link #2</a>
-        <a href="#">Link #3</a>
-    </div>
-</div>
-```
-
-<!-- START_VERBATIM -->
-<div x-data class="demo">
-<div x-popover>
-    <button x-popover:button>Open Dropdown</button>
-
-    <div x-popover:panel>
-        <a href="#">Link #1</a>
-        <a href="#">Link #2</a>
-        <a href="#">Link #3</a>
-    </div>
-</div>
-</div>
-<!-- END_VERBATIM -->
-
-## Directives
-
-* x-popover
-* x-popover:overlay
-* x-popover:panel
-* x-popover:title
-* x-popover:description
-
-## Magics
-
-* $popover
-
-## Props
-
-* open
-* @close
-* focus
-* static
-
-## Accessibility Notes
-
-## Keyboard Shortcuts
-

+ 0 - 79
packages/docs/src/en/ui/tabs.md

@@ -1,79 +0,0 @@
----
-order: 3
-title: Tabs
-description: ...
-graph_image: https://alpinejs.dev/social_tabs.jpg
----
-
-# Tabs
-
-Building a tabs component with Alpine might appear as simple as putting `x-show` on an various element styled as tabs. Unfortunately, much more goes into building robust, accessible tabs such as:
-
-* Cycle through tabs with arrow keys
-* Making only the active tab button focusable
-* Proper accessibility attributes
-
-...
-
-## A Basic Example
-
-```alpine
-<div x-tabs>
-    <div x-tabs:list>
-        <button x-tabs:tab>Tab #1</button>
-        <button x-tabs:tab>Tab #2</button>
-        <button x-tabs:tab>Tab #3</button>
-    </div>
-
-    <div x-tabs:panels>
-        <div x-tabs:panel>Tab Panel #1</div>
-        <div x-tabs:panel>Tab Panel #2</div>
-        <div x-tabs:panel>Tab Panel #3</div>
-    </div>
-</div>
-```
-
-<!-- START_VERBATIM -->
-<div x-data class="demo">
-<div x-tabs>
-    <div x-tabs:list>
-        <button x-tabs:tab>Tab #1</button>
-        <button x-tabs:tab>Tab #2</button>
-        <button x-tabs:tab>Tab #3</button>
-    </div>
-
-    <div x-tabs:panels>
-        <div x-tabs:panel>Tab Panel #1</div>
-        <div x-tabs:panel>Tab Panel #2</div>
-        <div x-tabs:panel>Tab Panel #3</div>
-    </div>
-</div>
-</div>
-<!-- END_VERBATIM -->
-
-## Directives
-
-* x-tabs
-* x-tabs:list
-* x-tabs:tab
-* x-tabs:panels
-* x-tabs:panel
-
-## Magics
-
-* $tab
-* $tabPanel
-
-## Props
-
-* manual
-* defaultIndex
-* selectedIndex
-* onChange
-* vertical
-* disabled
-
-## Accessibility Notes
-
-## Keyboard Shortcuts
-

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/ui",
-    "version": "3.10.2-beta.0",
+    "version": "3.10.2-beta.1",
     "description": "Headless UI components for Alpine",
     "author": "Caleb Porzio",
     "license": "MIT",

+ 474 - 7
packages/ui/src/combobox.js

@@ -1,83 +1,550 @@
 
 export default function (Alpine) {
-    Alpine.directive('combobox', (el, directive) => {
+    Alpine.directive('combobox', (el, directive, { evaluate }) => {
         if      (directive.value === 'input')        handleInput(el, Alpine)
         else if (directive.value === 'button')       handleButton(el, Alpine)
         else if (directive.value === 'label')        handleLabel(el, Alpine)
         else if (directive.value === 'options')      handleOptions(el, Alpine)
-        else if (directive.value === 'option')       handleOption(el, Alpine)
+        else if (directive.value === 'option')       handleOption(el, Alpine, directive, evaluate)
         else                                         handleRoot(el, Alpine)
     })
 
     Alpine.magic('comboboxOption', el => {
         let $data = Alpine.$data(el)
 
-        return {}
+        return $data.$item
     })
+
+    registerListStuff(Alpine)
 }
 
 function handleRoot(el, Alpine) {
     Alpine.bind(el, {
+        'x-id'() { return ['headlessui-combobox-button', 'headlessui-combobox-options', 'headlessui-combobox-label'] },
+        'x-list': '__value',
         'x-modelable': '__value',
         'x-data'() {
             return {
                 init() {
-                    //
+                    this.$nextTick(() => {
+                        this.syncInputValue()
+
+                        Alpine.effect(() => this.syncInputValue())
+                    })
                 },
                 __value: null,
+                __disabled: false,
+                __static: false,
+                __hold: false,
+                __displayValue: i => i,
                 __isOpen: false,
+                __optionsEl: null,
                 __open() {
+                    // @todo handle disabling the entire combobox.
                     if (this.__isOpen) return
                     this.__isOpen = true
+
+                    this.$list.activateSelectedOrFirst()
                 },
                 __close() {
+                    this.syncInputValue()
+
+                    if (this.__static) return
                     if (! this.__isOpen) return
 
                     this.__isOpen = false
+                    this.$list.active = null
+                },
+                syncInputValue() {
+                    if (this.$list.selected) this.$refs.__input.value = this.__displayValue(this.$list.selected)
                 },
             }
         },
+        '@mousedown.window'(e) {
+            if (
+                !! ! this.$refs.__input.contains(e.target)
+                && ! this.$refs.__button.contains(e.target)
+                && ! this.$refs.__options.contains(e.target)
+            ) {
+                this.__close()
+            }
+        }
     })
 }
 
 function handleInput(el, Alpine) {
     Alpine.bind(el, {
-        //
+        'x-ref': '__input',
+        ':id'() { return this.$id('headlessui-combobox-input') },
+        '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)
+                })
+
+                let displayValueFn = Alpine.bound(this.$el, 'display-value')
+                if (displayValueFn) this.$data.__displayValue = displayValueFn
+            })
+        },
+        '@input.stop'() { this.$data.__open(); this.$dispatch('change') },
+        '@change.stop'() {},
+        '@keydown.enter.prevent.stop'() { this.$list.selectActive(); this.$data.__close() },
+        '@keydown'(e) { this.$list.handleKeyboardNavigation(e) },
+        '@keydown.down'(e) { if(! this.$data.__isOpen) this.$data.__open(); },
+        '@keydown.up'(e) { if(! this.$data.__isOpen) this.$data.__open(); },
+        '@keydown.escape.prevent'(e) {
+            if (! this.$data.__static) e.stopPropagation()
+
+            this.$data.__close()
+        },
+        '@keydown.tab'() { if (this.$data.__isOpen) { this.$list.selectActive(); this.$data.__close() }},
     })
 }
 
 function handleButton(el, Alpine) {
     Alpine.bind(el, {
+        'x-ref': '__button',
+        ':id'() { return this.$id('headlessui-combobox-button') },
+        '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 },
+        'tabindex': '-1',
+        'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && ! this.$el.hasAttribute('type')) this.$el.type = 'button' },
         '@click'(e) {
+            if (this.$data.__disabled) return
             if (this.$data.__isOpen) {
                 this.$data.__close()
             } else {
                 e.preventDefault()
                 this.$data.__open()
             }
+
+            this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
+        },
+        '@keydown.down.prevent.stop'() {
+            if (! this.$data.__isOpen) {
+                this.$data.__open()
+                this.$list.activateSelectedOrFirst()
+            }
+
+            this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
+        },
+        '@keydown.up.prevent.stop'() {
+            if (! this.$data.__isOpen) {
+                this.$data.__open()
+                this.$list.activateSelectedOrLast()
+            }
+
+            this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
+        },
+        '@keydown.escape.prevent'(e) {
+            if (! this.$data.__static) e.stopPropagation()
+
+            this.$data.__close()
+            this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
         },
     })
 }
 
 function handleLabel(el, Alpine) {
     Alpine.bind(el, {
-        //
+        'x-ref': '__label',
+        ':id'() { return this.$id('headlessui-combobox-label') },
+        '@click'() { this.$refs.__input.focus({ preventScroll: true }) },
     })
 }
 
 function handleOptions(el, Alpine) {
     Alpine.bind(el, {
+        'x-ref': '__options',
+        'x-init'() {
+            this.$data.__optionsEl = this.$el
+
+            queueMicrotask(() => {
+                if (Alpine.bound(this.$el, 'static')) {
+                    this.$data.__open()
+                    this.$data.__static = true;
+                }
+
+                if (Alpine.bound(this.$el, 'hold')) {
+                    this.$data.__hold = true;
+                }
+            })
+
+            // 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')
+            })
+        },
+        'role': 'listbox',
+        ':id'() { return this.$id('headlessui-combobox-options') },
+        ':aria-labelledby'() { return this.$id('headlessui-combobox-button') },
+        ':aria-activedescendant'() { return this.$list.activeEl ? this.$list.activeEl.id : null },
         'x-show'() { return this.$data.__isOpen },
     })
 }
 
-function handleOption(el, Alpine) {
+function handleOption(el, Alpine, directive, evaluate) {
+    let value = evaluate(directive.expression)
+
     Alpine.bind(el, {
+        'role': 'option',
+        'x-item'() { return value },
+        ':id'() { return this.$id('headlessui-combobox-option') },
+        ':tabindex'() { return this.$item.disabled ? undefined : '-1' },
+        ':aria-selected'() { return this.$item.selected },
+        ':aria-disabled'() { return this.$item.disabled },
         '@click'(e) {
             if (this.$item.disabled) e.preventDefault()
             this.$item.select()
             this.$data.__close()
             this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
         },
+        '@focus'() {
+            if (this.$item.disabled) return this.$list.deactivate()
+            this.$item.activate()
+        },
+        '@pointermove'() {
+            if (this.$item.disabled || this.$item.active) return
+            this.$item.activate()
+        },
+        '@mousemove'() {
+            if (this.$item.disabled || this.$item.active) return
+            this.$item.activate()
+        },
+        '@pointerleave'() {
+            if (this.$item.disabled || ! this.$item.active || this.$data.__hold) return
+            this.$list.deactivate()
+        },
+        '@mouseleave'() {
+            if (this.$item.disabled || ! this.$item.active || this.$data.__hold) return
+            this.$list.deactivate()
+        },
     })
 }
+
+function registerListStuff(Alpine) {
+    Alpine.directive('list', (el, { expression, modifiers }, { evaluateLater, effect }) => {
+        let wrap = modifiers.includes('wrap')
+        let getOuterValue = () => null
+        let setOuterValue = () => {}
+
+        if (expression) {
+            let func = evaluateLater(expression)
+            getOuterValue = () => { let result; func(i => result = i); return result; }
+            let evaluateOuterSet = evaluateLater(`${expression} = __placeholder`)
+            setOuterValue = val => evaluateOuterSet(() => {}, { scope: { '__placeholder': val }})
+        }
+
+        let listEl = el
+
+        el._x_listState = {
+            wrap,
+            reactive: Alpine.reactive({
+                active: null,
+                selected: null,
+            }),
+            get active() { return this.reactive.active },
+            get selected() { return this.reactive.selected },
+            get activeEl() {
+                this.reactive.active
+
+                let item = this.items.find(i => i.value === this.reactive.active)
+
+                return item && item.el
+            },
+            get selectedEl() {
+                let item = this.items.find(i => i.value === this.reactive.selected)
+
+                return item && item.el
+            },
+            set active(value) { this.setActive(value) },
+            set selected(value) { this.setSelected(value) },
+            setSelected(value) {
+                let item = this.items.find(i => i.value === value)
+
+                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()
+            },
+            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 }) => {
+        let value
+        el._x_listItem = true
+
+        if (expression) value = evaluate(expression)
+
+        let listEl = Alpine.findClosest(el, el => el._x_listState)
+
+        console.log(value)
+        listEl._x_listState.addItem(el, value)
+
+        queueMicrotask(() => {
+            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
+        let item = listEl._x_listState.items.find(i => i.el === el)
+
+        return {
+            activate(callback = () => {}) {
+                state.setActive(item.value)
+
+                callback(item)
+            },
+            deactivate() {
+                if (Alpine.raw(state.active) === Alpine.raw(item.value)) state.setActive(null)
+            },
+            select(callback = () => {}) {
+                state.setSelected(item.value)
+
+                callback(item)
+            },
+            isFirst() {
+                return state.items.findIndex(i => i.el.isSameNode(el)) === 0
+            },
+            get active() {
+                if (state.reactive.active) return state.reactive.active === item.value
+
+                return null
+            },
+            get selected() {
+                if (state.reactive.selected) return state.reactive.selected === item.value
+
+                return null
+            },
+            get disabled() {
+                return item.disabled
+            },
+            get el() { return item.el },
+            get value() { return item.value },
+        }
+    }
+}
+
+/* <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> */

+ 0 - 1
packages/ui/src/dialog.js

@@ -46,7 +46,6 @@ function handleRoot(el, Alpine) {
                     else this.__isOpenState = false
                 },
                 get __isOpen() {
-                    console.log('foo', Alpine.bound(el, 'static', this.__isOpenState))
                     return Alpine.bound(el, 'static', this.__isOpenState)
                 },
             }

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

@@ -1,7 +1,5 @@
 import dialog from './dialog'
-import combobox from './combobox'
 
 export default function (Alpine) {
     dialog(Alpine)
-    combobox(Alpine)
 }