ソースを参照

Merge branch 'main' into add-navigate

Caleb Porzio 2 年 前
コミット
2199fb4b46
44 ファイル変更1617 行追加191 行削除
  1. 1 1
      packages/alpinejs/package.json
  2. 6 0
      packages/alpinejs/src/binds.js
  3. 23 2
      packages/alpinejs/src/directives.js
  4. 12 38
      packages/alpinejs/src/directives/x-bind.js
  5. 3 1
      packages/alpinejs/src/directives/x-show.js
  6. 6 6
      packages/alpinejs/src/utils/bind.js
  7. 1 1
      packages/collapse/package.json
  8. 1 1
      packages/docs/package.json
  9. 1 1
      packages/docs/src/en/directives/on.md
  10. 17 0
      packages/docs/src/en/directives/show.md
  11. 1 1
      packages/docs/src/en/essentials/installation.md
  12. 6 0
      packages/docs/src/en/ui.md
  13. 1 1
      packages/focus/package.json
  14. 1 1
      packages/focus/src/index.js
  15. 1 1
      packages/intersect/package.json
  16. 1 1
      packages/mask/package.json
  17. 1 1
      packages/morph/package.json
  18. 3 1
      packages/morph/src/dom.js
  19. 1 1
      packages/persist/package.json
  20. 0 5
      packages/portal/builds/cdn.js
  21. 0 3
      packages/portal/builds/module.js
  22. 0 62
      packages/portal/src/index.js
  23. 5 0
      packages/ui/builds/cdn.js
  24. 3 0
      packages/ui/builds/module.js
  25. 3 3
      packages/ui/package.json
  26. 550 0
      packages/ui/src/combobox.js
  27. 92 0
      packages/ui/src/dialog.js
  28. 5 0
      packages/ui/src/index.js
  29. 191 0
      packages/ui/src/popover.js
  30. 136 0
      packages/ui/src/tabs.js
  31. 1 0
      scripts/build.js
  32. 15 0
      tests/cypress/integration/custom-bind.spec.js
  33. 6 6
      tests/cypress/integration/directives/x-bind.spec.js
  34. 21 21
      tests/cypress/integration/directives/x-for.spec.js
  35. 8 8
      tests/cypress/integration/directives/x-if.spec.js
  36. 18 1
      tests/cypress/integration/directives/x-show.spec.js
  37. 6 6
      tests/cypress/integration/directives/x-teleport.spec.js
  38. 13 0
      tests/cypress/integration/plugins/morph.spec.js
  39. 16 16
      tests/cypress/integration/plugins/persist.spec.js
  40. 204 0
      tests/cypress/integration/plugins/ui/dialog.spec.js
  41. 140 0
      tests/cypress/integration/plugins/ui/popover.spec.js
  42. 85 0
      tests/cypress/integration/plugins/ui/tabs.spec.js
  43. 1 0
      tests/cypress/spec.html
  44. 11 1
      tests/cypress/utils.js

+ 1 - 1
packages/alpinejs/package.json

@@ -1,6 +1,6 @@
 {
     "name": "alpinejs",
-    "version": "3.10.2",
+    "version": "3.10.3",
     "description": "The rugged, minimal JavaScript framework",
     "author": "Caleb Porzio",
     "license": "MIT",

+ 6 - 0
packages/alpinejs/src/binds.js

@@ -26,6 +26,12 @@ export function injectBindingProviders(obj) {
     return obj
 }
 
+export function addVirtualBindings(el, bindings) {
+    let getBindings = typeof bindings !== 'function' ? () => bindings : bindings
+
+    el._x_virtualDirectives = getBindings()
+}
+
 export function applyBindingsObject(el, obj, original) {
     let cleanupRunners = []
 

+ 23 - 2
packages/alpinejs/src/directives.js

@@ -20,9 +20,31 @@ export function directive(name, callback) {
 }
 
 export function directives(el, attributes, originalAttributeOverride) {
+    attributes = Array.from(attributes)
+
+    if (el._x_virtualDirectives) {
+        let vAttributes = Object.entries(el._x_virtualDirectives).map(([name, value]) => ({ name, value }))
+
+        let staticAttributes = attributesOnly(vAttributes)
+
+        // Handle binding normal HTML attributes (non-Alpine directives).
+        vAttributes = vAttributes.map(attribute => {
+            if (staticAttributes.find(attr => attr.name === attribute.name)) {
+                return {
+                    name: `x-bind:${attribute.name}`,
+                    value: `"${attribute.value}"`,
+                }
+            }
+
+            return attribute
+        })
+
+        attributes = attributes.concat(vAttributes)
+    }
+
     let transformedAttributeMap = {}
 
-    let directives = Array.from(attributes)
+    let directives = attributes
         .map(toTransformedAttributes((newName, oldName) => transformedAttributeMap[newName] = oldName))
         .filter(outNonAlpineAttributes)
         .map(toParsedDirectives(transformedAttributeMap, originalAttributeOverride))
@@ -178,7 +200,6 @@ let directiveOrder = [
     'if',
     DEFAULT,
     'teleport',
-    'element',
 ]
 
 function byPriority(a, b) {

+ 12 - 38
packages/alpinejs/src/directives/x-bind.js

@@ -1,14 +1,23 @@
-import { attributesOnly, directive, directives, into, mapAttributes, prefix, startingWith } from '../directives'
+import { directive, into, mapAttributes, prefix, startingWith } from '../directives'
 import { evaluateLater } from '../evaluator'
 import { mutateDom } from '../mutation'
 import bind from '../utils/bind'
-import { injectBindingProviders } from '../binds'
+import { applyBindingsObject, injectBindingProviders } from '../binds'
 
 mapAttributes(startingWith(':', into(prefix('bind:'))))
 
 directive('bind', (el, { value, modifiers, expression, original }, { effect }) => {
     if (! value) {
-        return applyBindingsObject(el, expression, original, effect)
+        let bindingProviders = {}
+        injectBindingProviders(bindingProviders)
+
+        let getBindings = evaluateLater(el, expression)
+
+        getBindings(bindings => {
+            applyBindingsObject(el, bindings, original)
+        }, { scope: bindingProviders } )
+
+        return
     }
 
     if (value === 'key') return storeKeyForXFor(el, expression)
@@ -23,41 +32,6 @@ directive('bind', (el, { value, modifiers, expression, original }, { effect }) =
     }))
 })
 
-function applyBindingsObject(el, expression, original, effect) {
-    let bindingProviders = {}
-    injectBindingProviders(bindingProviders)
-
-    let getBindings = evaluateLater(el, expression)
-
-    let cleanupRunners = []
-
-    while (cleanupRunners.length) cleanupRunners.pop()()
-
-    getBindings(bindings => {
-        let attributes = Object.entries(bindings).map(([name, value]) => ({ name, value }))
-
-        let staticAttributes = attributesOnly(attributes)
-
-        // Handle binding normal HTML attributes (non-Alpine directives).
-        attributes = attributes.map(attribute => {
-            if (staticAttributes.find(attr => attr.name === attribute.name)) {
-                return {
-                    name: `x-bind:${attribute.name}`,
-                    value: `"${attribute.value}"`,
-                }
-            }
-
-            return attribute
-        })
-
-        directives(el, attributes, original).map(handle => {
-            cleanupRunners.push(handle.runCleanups)
-
-            handle()
-        })
-    }, { scope: bindingProviders } )
-}
-
 function storeKeyForXFor(el, expression) {
     el._x_keyExpression = expression
 }

+ 3 - 1
packages/alpinejs/src/directives/x-show.js

@@ -9,7 +9,9 @@ directive('show', (el, { modifiers, expression }, { effect }) => {
     // We're going to set this function on the element directly so that
     // other plugins like "Collapse" can overwrite them with their own logic.
     if (! el._x_doHide) el._x_doHide = () => {
-        mutateDom(() => el.style.display = 'none')
+        mutateDom(() => {
+            el.style.setProperty('display', 'none', modifiers.includes('important') ? 'important' : undefined)
+        })
     }
 
     if (! el._x_doShow) el._x_doShow = () => {

+ 6 - 6
packages/alpinejs/src/utils/bind.js

@@ -7,7 +7,7 @@ export default function bind(el, name, value, modifiers = []) {
     if (! el._x_bindings) el._x_bindings = reactive({})
 
     el._x_bindings[name] = value
-   
+
     name = modifiers.includes('camel') ? camelCase(name) : name
 
     switch (name) {
@@ -129,21 +129,21 @@ function attributeShouldntBePreservedIfFalsy(name) {
 }
 
 export function getBinding(el, name, fallback) {
-    // First let's get it out of Alpine bound data. 
+    // First let's get it out of Alpine bound data.
     if (el._x_bindings && el._x_bindings[name] !== undefined) return el._x_bindings[name]
 
-    // If not, we'll return the literal attribute. 
+    // If not, we'll return the literal attribute.
     let attr = el.getAttribute(name)
 
     // Nothing bound:
     if (attr === null) return typeof fallback === 'function' ? fallback() : fallback
-   
+
     if (isBooleanAttr(name)) {
         return !! [name, 'true'].includes(attr)
     }
 
-    // The case of a custom attribute with no value. Ex: <div manual> 
+    // The case of a custom attribute with no value. Ex: <div manual>
     if (attr === '') return true
-   
+
     return attr
 }

+ 1 - 1
packages/collapse/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/collapse",
-    "version": "3.10.2",
+    "version": "3.10.3",
     "description": "Collapse and expand elements with robust animations",
     "author": "Caleb Porzio",
     "license": "MIT",

+ 1 - 1
packages/docs/package.json

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

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

@@ -81,7 +81,7 @@ For easy reference, here is a list of common keys you may want to listen for.
 | `.space`                    | Space                       |
 | `.ctrl`                     | Ctrl                        |
 | `.cmd`                      | Cmd                         |
-| `.meta`                     | Cmd on Mac, Ctrl on Windows |
+| `.meta`                     | Cmd on Mac, Windows key on Windows |
 | `.alt`                      | Alt                         |
 | `.up` `.down` `.left` `.right` | Up/Down/Left/Right arrows   |
 | `.escape`                   | Escape                      |

+ 17 - 0
packages/docs/src/en/directives/show.md

@@ -37,3 +37,20 @@ If you want to apply smooth transitions to the `x-show` behavior, you can use it
     </div>
 </div>
 ```
+
+<a name="using-the-important-modifier"></a>
+## Using the important modifier
+
+Sometimes you need to apply a little more force to actually hide an element. In cases where a CSS selector applies the `display` property with the `!important` flag, it will take precedence over the inline style set by Alpine.
+
+In these cases you may use the `.important` modifier to set the inline style to `display: none !important`.
+
+```alpine
+<div x-data="{ open: false }">
+    <button x-on:click="open = ! open">Toggle Dropdown</button>
+
+    <div x-show.important="open">
+        Dropdown Contents...
+    </div>
+</div>
+```

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

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

+ 6 - 0
packages/docs/src/en/ui.md

@@ -0,0 +1,6 @@
+---
+order: 5
+title: UI
+font-type: mono
+type: sub-directory
+---

+ 1 - 1
packages/focus/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/focus",
-    "version": "3.10.2",
+    "version": "3.10.3",
     "description": "Manage focus within a page",
     "author": "Caleb Porzio",
     "license": "MIT",

+ 1 - 1
packages/focus/src/index.js

@@ -1,5 +1,5 @@
 import { createFocusTrap } from 'focus-trap'
-import { focusable, tabbable, isFocusable } from 'tabbable'
+import { focusable, isFocusable } from 'tabbable'
 
 export default function (Alpine) {
     let lastFocused

+ 1 - 1
packages/intersect/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/intersect",
-    "version": "3.10.2",
+    "version": "3.10.3",
     "description": "Trigger JavaScript when an element enters the viewport",
     "author": "Caleb Porzio",
     "license": "MIT",

+ 1 - 1
packages/mask/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/mask",
-    "version": "3.10.2",
+    "version": "3.10.3",
     "description": "An Alpine plugin for input masking",
     "author": "Caleb Porzio",
     "license": "MIT",

+ 1 - 1
packages/morph/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/morph",
-    "version": "3.10.2",
+    "version": "3.10.3",
     "description": "Diff and patch a block of HTML on a page with an HTML template",
     "author": "Caleb Porzio",
     "license": "MIT",

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

@@ -57,7 +57,9 @@ export function dom(el) {
 }
 
 export function createElement(html) {
-    return document.createRange().createContextualFragment(html).firstElementChild
+    const template = document.createElement('template')
+    template.innerHTML = html
+    return template.content.firstElementChild
 }
 
 export function textOrComment(el) {

+ 1 - 1
packages/persist/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/persist",
-    "version": "3.10.2",
+    "version": "3.10.3",
     "description": "Persist Alpine data across page loads",
     "author": "Caleb Porzio",
     "license": "MIT",

+ 0 - 5
packages/portal/builds/cdn.js

@@ -1,5 +0,0 @@
-import portal from '../src/index.js'
-
-document.addEventListener('alpine:init', () => {
-    window.Alpine.plugin(portal)
-})

+ 0 - 3
packages/portal/builds/module.js

@@ -1,3 +0,0 @@
-import portal from './../src/index.js'
-
-export default portal

+ 0 - 62
packages/portal/src/index.js

@@ -1,62 +0,0 @@
-export default function (Alpine) {
-    let portals = new MapSet
-
-    Alpine.directive('portal', (el, { expression }, { effect, cleanup }) => {
-        let clone = el.content.cloneNode(true).firstElementChild
-        // Add reference to element on <template x-portal, and visa versa.
-        el._x_portal = clone
-        clone._x_portal_back = el
-    
-        let init = (target) => {
-            // Forward event listeners:
-            if (el._x_forwardEvents) {
-                el._x_forwardEvents.forEach(eventName => {
-                    clone.addEventListener(eventName, e => {
-                        e.stopPropagation()
-                        
-                        el.dispatchEvent(new e.constructor(e.type, e))
-                    })
-                })
-            }
-    
-            Alpine.addScopeToNode(clone, {}, el)
-    
-            Alpine.mutateDom(() => {
-                target.before(clone)
-    
-                Alpine.initTree(clone)
-            })
-    
-            cleanup(() => {
-                clone.remove()
-               
-                portals.delete(expression, init) 
-            })
-        }
-    
-        portals.add(expression, init)
-    })
-    
-    Alpine.addInitSelector(() => `[${Alpine.prefixed('portal-target')}]`)
-    Alpine.directive('portal-target', (el, { expression }) => {
-        portals.each(expression, initPortal => initPortal(el))
-    })
-}
-
-class MapSet {
-    map = new Map
-
-    get(name) {
-        if (! this.map.has(name)) this.map.set(name, new Set)
-
-        return this.map.get(name)
-    }
-
-    add(name, value) { this.get(name).add(value) }
-
-    each(name, callback) { this.map.get(name).forEach(callback) }
-
-    delete(name, value) {
-        this.map.get(name).delete(value)
-    }
-}

+ 5 - 0
packages/ui/builds/cdn.js

@@ -0,0 +1,5 @@
+import ui from '../src/index.js'
+
+document.addEventListener('alpine:init', () => {
+    window.Alpine.plugin(ui)
+})

+ 3 - 0
packages/ui/builds/module.js

@@ -0,0 +1,3 @@
+import ui from '../src/index.js'
+
+export default ui

+ 3 - 3
packages/portal/package.json → packages/ui/package.json

@@ -1,7 +1,7 @@
 {
-    "name": "@alpinejs/portal",
-    "version": "3.6.1-beta.0",
-    "description": "Send Alpine templates to other parts of the DOM",
+    "name": "@alpinejs/ui",
+    "version": "3.10.3-beta.0",
+    "description": "Headless UI components for Alpine",
     "author": "Caleb Porzio",
     "license": "MIT",
     "main": "dist/module.cjs.js",

+ 550 - 0
packages/ui/src/combobox.js

@@ -0,0 +1,550 @@
+
+export default function (Alpine) {
+    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, directive, evaluate)
+        else                                         handleRoot(el, Alpine)
+    })
+
+    Alpine.magic('comboboxOption', el => {
+        let $data = Alpine.$data(el)
+
+        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, 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> */

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

@@ -0,0 +1,92 @@
+
+export default function (Alpine) {
+    Alpine.directive('dialog', (el, directive) => {
+        if      (directive.value === 'overlay')     handleOverlay(el, Alpine)
+        else if (directive.value === 'panel')       handlePanel(el, Alpine)
+        else if (directive.value === 'title')       handleTitle(el, Alpine)
+        else if (directive.value === 'description') handleDescription(el, Alpine)
+        else                                        handleRoot(el, Alpine)
+    })
+
+    Alpine.magic('dialog', el => {
+        let $data = Alpine.$data(el)
+
+        return {
+            get open() {
+                return $data.__isOpen
+            },
+            close() {
+                $data.__close()
+            }
+        }
+    })
+}
+
+function handleRoot(el, Alpine) {
+    Alpine.bind(el, {
+        'x-data'() {
+            return {
+                init() {
+                    // If the user chose to use :open and @close instead of x-model.
+                    (Alpine.bound(el, 'open') !== undefined) && Alpine.effect(() => {
+                        this.__isOpenState = Alpine.bound(el, 'open')
+                    })
+
+                    if (Alpine.bound(el, 'initial-focus') !== undefined) this.$watch('__isOpenState', () => {
+                        if (! this.__isOpenState) return
+
+                        setTimeout(() => {
+                            Alpine.bound(el, 'initial-focus').focus()
+                        }, 0);
+                    })
+                },
+                __isOpenState: false,
+                __close() {
+                    if (Alpine.bound(el, 'open')) this.$dispatch('close')
+                    else this.__isOpenState = false
+                },
+                get __isOpen() {
+                    return Alpine.bound(el, 'static', this.__isOpenState)
+                },
+            }
+        },
+        'x-modelable': '__isOpenState',
+        'x-id'() { return ['alpine-dialog-title', 'alpine-dialog-description'] },
+        'x-show'() { return this.__isOpen },
+        'x-trap.inert.noscroll'() { return this.__isOpen },
+        '@keydown.escape'() { this.__close() },
+        ':aria-labelledby'() { return this.$id('alpine-dialog-title') },
+        ':aria-describedby'() { return this.$id('alpine-dialog-description') },
+        'role': 'dialog',
+        'aria-modal': 'true',
+    })
+}
+
+function handleOverlay(el, Alpine) {
+    Alpine.bind(el, {
+        'x-init'() { if (this.$data.__isOpen === undefined) console.warn('"x-dialog:overlay" is missing a parent element with "x-dialog".') },
+        'x-show'() { return this.__isOpen },
+        '@click.prevent.stop'() { this.$data.__close() },
+    })
+}
+
+function handlePanel(el, Alpine) {
+    Alpine.bind(el, {
+        '@click.outside'() { this.$data.__close() },
+        'x-show'() { return this.$data.__isOpen },
+    })
+}
+
+function handleTitle(el, Alpine) {
+    Alpine.bind(el, {
+        'x-init'() { if (this.$data.__isOpen === undefined) console.warn('"x-dialog:title" is missing a parent element with "x-dialog".') },
+        ':id'() { return this.$id('alpine-dialog-title') },
+    })
+}
+
+function handleDescription(el, Alpine) {
+    Alpine.bind(el, {
+        ':id'() { return this.$id('alpine-dialog-description') },
+    })
+}
+

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

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

+ 191 - 0
packages/ui/src/popover.js

@@ -0,0 +1,191 @@
+
+export default function (Alpine) {
+    Alpine.directive('popover', (el, directive) => {
+        if      (! directive.value)                 handleRoot(el, Alpine)
+        else if (directive.value === 'overlay')     handleOverlay(el, Alpine)
+        else if (directive.value === 'button')      handleButton(el, Alpine)
+        else if (directive.value === 'panel')       handlePanel(el, Alpine)
+        else if (directive.value === 'group')       handleGroup(el, Alpine)
+    })
+
+    Alpine.magic('popover', el => {
+        let $data = Alpine.$data(el)
+
+        return {
+            get open() {
+                return $data.__isOpen
+            }
+        }
+    })
+}
+
+function handleRoot(el, Alpine) {
+    Alpine.bind(el, {
+        'x-id'() { return ['alpine-popover-button', 'alpine-popover-panel'] },
+        'x-data'() {
+            return {
+                init() {
+                    if (this.$data.__groupEl) {
+                        this.$data.__groupEl.addEventListener('__close-others', ({ detail }) => {
+                            if (detail.el.isSameNode(this.$el)) return
+
+                            this.__close(false)
+                        })
+                    }
+                },
+                __buttonEl: undefined,
+                __panelEl: undefined,
+                __isOpen: false,
+                __open() {
+                    this.__isOpen = true
+
+                    this.$dispatch('__close-others', { el: this.$el })
+                },
+                __toggle() {
+                    this.__isOpen ? this.__close() : this.__open()
+                },
+                __close(el) {
+                    this.__isOpen = false
+
+                    if (el === false) return
+
+                    el = el || this.$data.__buttonEl
+
+                    if (document.activeElement.isSameNode(el)) return
+
+                    setTimeout(() => el.focus())
+                },
+                __contains(outer, inner) {
+                    return !! Alpine.findClosest(inner, el => el.isSameNode(outer))
+                }
+            }
+        },
+        '@keydown.escape.stop.prevent'() {
+            this.__close()
+        },
+        '@focusin.window'() {
+            if (this.$data.__groupEl) {
+                if (! this.$data.__contains(this.$data.__groupEl, document.activeElement)) {
+                    this.$data.__close(false)
+                }
+
+                return
+            }
+
+            if (! this.$data.__contains(this.$el, document.activeElement)) {
+                this.$data.__close(false)
+            }
+        },
+    })
+}
+
+function handleButton(el, Alpine) {
+    Alpine.bind(el, {
+        'x-ref': 'button',
+        ':id'() { return this.$id('alpine-popover-button') },
+        ':aria-expanded'() { return this.$data.__isOpen },
+        ':aria-controls'() { return this.$data.__isOpen && this.$id('alpine-popover-panel') },
+        'x-init'() {
+            if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button'
+
+            this.$data.__buttonEl = this.$el
+        },
+        '@click'() { this.$data.__toggle() },
+        '@keydown.tab'(e) {
+            if (! e.shiftKey && this.$data.__isOpen) {
+                let firstFocusableEl = this.$focus.within(this.$data.__panelEl).getFirst()
+
+                if (firstFocusableEl) {
+                    e.preventDefault()
+                    e.stopPropagation()
+
+                    this.$focus.focus(firstFocusableEl)
+                }
+            }
+        },
+        '@keyup.tab'(e) {
+            if (this.$data.__isOpen) {
+                // Check if the last focused element was "after" this one
+                let lastEl = this.$focus.previouslyFocused()
+
+                if (! lastEl) return
+
+                if (
+                    // Make sure the last focused wasn't part of this popover.
+                    (! this.$data.__buttonEl.contains(lastEl) && ! this.$data.__panelEl.contains(lastEl))
+                    // Also make sure it appeared "after" this button in the DOM.
+                    && (lastEl && (this.$el.compareDocumentPosition(lastEl) & Node.DOCUMENT_POSITION_FOLLOWING))
+                ) {
+                    e.preventDefault()
+                    e.stopPropagation()
+
+                    this.$focus.within(this.$data.__panelEl).last()
+                }
+            }
+        },
+        '@keydown.space.stop.prevent'() { this.$data.__toggle() },
+        '@keydown.enter.stop.prevent'() { this.$data.__toggle() },
+        // This is to stop Firefox from firing a "click".
+        '@keyup.space.stop.prevent'() { },
+    })
+}
+
+function handlePanel(el, Alpine) {
+    Alpine.bind(el, {
+        'x-init'() { this.$data.__panelEl = this.$el },
+        'x-effect'() {
+            this.$data.__isOpen && Alpine.bound(el, 'focus') && this.$focus.first()
+        },
+        'x-ref': 'panel',
+        ':id'() { return this.$id('alpine-popover-panel') },
+        'x-show'() { return this.$data.__isOpen },
+        '@mousedown.window'($event) {
+            if (! this.$data.__isOpen) return
+            if (this.$data.__contains(this.$data.__buttonEl, $event.target)) return
+            if (this.$data.__contains(this.$el, $event.target)) return
+
+            if (! this.$focus.focusable($event.target)) {
+                this.$data.__close()
+            }
+        },
+        '@keydown.tab'(e) {
+            if (e.shiftKey && this.$focus.isFirst(e.target)) {
+                e.preventDefault()
+                e.stopPropagation()
+                Alpine.bound(el, 'focus') ? this.$data.__close() : this.$data.__buttonEl.focus()
+            } else if (! e.shiftKey && this.$focus.isLast(e.target)) {
+                e.preventDefault()
+                e.stopPropagation()
+
+                // Get the next panel button:
+                let els = this.$focus.within(document).all()
+                let buttonIdx = els.indexOf(this.$data.__buttonEl)
+
+                let nextEls = els
+                    .splice(buttonIdx + 1) // Elements after button
+                    .filter(el => ! this.$el.contains(el)) // Ignore items in panel
+
+                nextEls[0].focus()
+
+                Alpine.bound(el, 'focus') && this.$data.__close(false)
+            }
+        },
+    })
+}
+
+function handleGroup(el, Alpine) {
+    Alpine.bind(el, {
+        'x-ref': 'container',
+        'x-data'() {
+            return {
+                __groupEl: this.$el,
+            }
+        },
+    })
+}
+
+function handleOverlay(el, Alpine) {
+    Alpine.bind(el, {
+        'x-show'() { return this.$data.__isOpen }
+    })
+}

+ 136 - 0
packages/ui/src/tabs.js

@@ -0,0 +1,136 @@
+
+export default function (Alpine) {
+    Alpine.directive('tabs', (el, directive) => {
+        if      (! directive.value)                handleRoot(el, Alpine)
+        else if (directive.value === 'list')       handleList(el, Alpine)
+        else if (directive.value === 'tab')        handleTab(el, Alpine)
+        else if (directive.value === 'panels')     handlePanels(el, Alpine)
+        else if (directive.value === 'panel')      handlePanel(el, Alpine)
+    })
+
+    Alpine.magic('tab', el => {
+        let $data = Alpine.$data(el)
+
+        return {
+            get selected() {
+                return $data.__selectedIndex === $data.__tabs.indexOf($data.__tabEl)
+            }
+        }
+    })
+
+    Alpine.magic('tabPanel', el => {
+        let $data = Alpine.$data(el)
+
+        return {
+            get selected() {
+                return $data.__selectedIndex === $data.__panels.indexOf($data.__panelEl)
+            }
+        }
+    })
+}
+
+function handleRoot(el, Alpine) {
+    Alpine.bind(el, {
+        'x-data'() {
+            return {
+                init() {
+                    queueMicrotask(() => {
+                        let defaultIndex = this.__selectedIndex || Number(Alpine.bound(this.$el, 'default-index', 0))
+                        let tabs = this.__activeTabs()
+                        let clamp = (number, min, max) => Math.min(Math.max(number, min), max)
+
+                        this.__selectedIndex = clamp(defaultIndex, 0, tabs.length -1)
+
+                        Alpine.effect(() => {
+                            this.__manualActivation = Alpine.bound(this.$el, 'manual', false)
+                        })
+                    })
+                },
+                __tabs: [],
+                __panels: [],
+                __selectedIndex: null,
+                __tabGroupEl: undefined,
+                __manualActivation: false,
+                __addTab(el) { this.__tabs.push(el) },
+                __addPanel(el) { this.__panels.push(el) },
+                __selectTab(el) {
+                    this.__selectedIndex = this.__tabs.indexOf(el)
+                },
+                __activeTabs() {
+                   return this.__tabs.filter(i => !i.__disabled)
+                },
+            }
+        }
+    })
+}
+
+function handleList(el, Alpine) {
+    Alpine.bind(el, {
+        'x-init'() { this.$data.__tabGroupEl = this.$el }
+    })
+}
+
+function handleTab(el, Alpine) {
+    let options = {}
+    Alpine.bind(el, {
+        'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button' },
+        'x-data'() { return {
+            init() {
+                this.__tabEl = this.$el
+                this.$data.__addTab(this.$el)
+                this.$el.__disabled = options.disabled
+            },
+            __tabEl: undefined,
+        }},
+        '@click'() {
+            if (this.$el.__disabled) return
+
+            this.$data.__selectTab(this.$el)
+
+            this.$el.focus()
+        },
+        '@keydown.enter.prevent.stop'() { this.__selectTab(this.$el) },
+        '@keydown.space.prevent.stop'() { this.__selectTab(this.$el) },
+        '@keydown.home.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).first() },
+        '@keydown.page-up.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).first() },
+        '@keydown.end.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).last() },
+        '@keydown.page-down.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).last() },
+        '@keydown.down.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).withWrapAround().next() },
+        '@keydown.right.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).withWrapAround().next() },
+        '@keydown.up.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).withWrapAround().prev() },
+        '@keydown.left.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).withWrapAround().prev() },
+        ':tabindex'() { return this.$tab.selected ? 0 : -1 },
+        '@focus'() {
+            if (this.$data.__manualActivation) {
+                this.$el.focus()
+            } else {
+                if (this.$el.__disabled) return
+
+                this.$data.__selectTab(this.$el)
+
+                this.$el.focus()
+            }
+        },
+    })
+}
+
+function handlePanels(el, Alpine) {
+    Alpine.bind(el, {
+        //
+    })
+}
+
+function handlePanel(el, Alpine) {
+    Alpine.bind(el, {
+        ':tabindex'() { return this.$tabPanel.selected ? 0 : -1 },
+        'x-data'() { return {
+            init() {
+                this.__panelEl = this.$el
+                this.$data.__addPanel(this.$el)
+            },
+            __panelEl: undefined,
+        }},
+        'x-show'() { return this.$tabPanel.selected },
+    })
+}
+

+ 1 - 0
scripts/build.js

@@ -14,6 +14,7 @@ let brotliSize = require('brotli-size');
     'focus',
     'mask',
     'navigate',
+    'ui',
 ]).forEach(package => {
     if (! fs.existsSync(`./packages/${package}/dist`)) {
         fs.mkdirSync(`./packages/${package}/dist`, 0744);

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

@@ -44,3 +44,18 @@ test('can consume custom bind as function',
     `,
     ({ get }) => get('div').should(haveText('bar'))
 )
+
+test('can bind directives individually to an element',
+    html`
+        <script>
+            document.addEventListener('alpine:init', () => {
+                Alpine.bind(document.querySelector('#one'), () => ({
+                    'x-text'() { return 'foo' },
+                }))
+            })
+        </script>
+
+        <div x-data id="one"></div>
+    `,
+    ({ get }) => get('div').should(haveText('foo'))
+)

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

@@ -49,12 +49,12 @@ test('non-boolean attributes set to null/undefined/false are removed from the el
         </div>
     `,
     ({ get }) => {
-        get('a:nth-child(1)').should(notHaveAttribute('href'))
-        get('a:nth-child(2)').should(notHaveAttribute('href'))
-        get('a:nth-child(3)').should(notHaveAttribute('href'))
-        get('span:nth-child(1)').should(notHaveAttribute('visible'))
-        get('span:nth-child(2)').should(notHaveAttribute('visible'))
-        get('span:nth-child(3)').should(notHaveAttribute('visible'))
+        get('a:nth-of-type(1)').should(notHaveAttribute('href'))
+        get('a:nth-of-type(2)').should(notHaveAttribute('href'))
+        get('a:nth-of-type(3)').should(notHaveAttribute('href'))
+        get('span:nth-of-type(1)').should(notHaveAttribute('visible'))
+        get('span:nth-of-type(2)').should(notHaveAttribute('visible'))
+        get('span:nth-of-type(3)').should(notHaveAttribute('visible'))
     }
 )
 

+ 21 - 21
tests/cypress/integration/directives/x-for.spec.js

@@ -1,4 +1,4 @@
-import { beVisible, haveLength, haveText, html, notBeVisible, test } from '../../utils'
+import { exist, haveLength, haveText, html, notExist, test } from '../../utils'
 
 test('renders loops with x-for',
     html`
@@ -12,7 +12,7 @@ test('renders loops with x-for',
     `,
     ({ get }) => {
         get('span:nth-of-type(1)').should(haveText('foo'))
-        get('span:nth-of-type(2)').should(notBeVisible())
+        get('span:nth-of-type(2)').should(notExist())
         get('button').click()
         get('span:nth-of-type(1)').should(haveText('foo'))
         get('span:nth-of-type(2)').should(haveText('bar'))
@@ -47,9 +47,9 @@ test('renders loops with x-for that have space or newline',
     `,
     ({ get }) => {
         get('#1 span:nth-of-type(1)').should(haveText('foo'))
-        get('#1 span:nth-of-type(2)').should(notBeVisible())
+        get('#1 span:nth-of-type(2)').should(notExist())
         get('#2 span:nth-of-type(1)').should(haveText('foo'))
-        get('#2 span:nth-of-type(2)').should(notBeVisible())
+        get('#2 span:nth-of-type(2)').should(notExist())
         get('button').click()
         get('#1 span:nth-of-type(1)').should(haveText('foo'))
         get('#1 span:nth-of-type(2)').should(haveText('bar'))
@@ -107,9 +107,9 @@ test('removes all elements when array is empty and previously had one item',
         </div>
     `,
     ({ get }) => {
-        get('span').should(beVisible())
+        get('span').should(exist())
         get('button').click()
-        get('span').should(notBeVisible())
+        get('span').should(notExist())
     }
 )
 
@@ -124,13 +124,13 @@ test('removes all elements when array is empty and previously had multiple items
         </div>
     `,
     ({ get }) => {
-        get('span:nth-of-type(1)').should(beVisible())
-        get('span:nth-of-type(2)').should(beVisible())
-        get('span:nth-of-type(3)').should(beVisible())
+        get('span:nth-of-type(1)').should(exist())
+        get('span:nth-of-type(2)').should(exist())
+        get('span:nth-of-type(3)').should(exist())
         get('button').click()
-        get('span:nth-of-type(1)').should(notBeVisible())
-        get('span:nth-of-type(2)').should(notBeVisible())
-        get('span:nth-of-type(3)').should(notBeVisible())
+        get('span:nth-of-type(1)').should(notExist())
+        get('span:nth-of-type(2)').should(notExist())
+        get('span:nth-of-type(3)').should(notExist())
     }
 )
 
@@ -148,11 +148,11 @@ test('elements inside of loop are reactive',
         </div>
     `,
     ({ get }) => {
-        get('span').should(beVisible())
+        get('span').should(exist())
         get('h1').should(haveText('first'))
         get('h2').should(haveText('bar'))
         get('button').click()
-        get('span').should(beVisible())
+        get('span').should(exist())
         get('h1').should(haveText('first'))
         get('h2').should(haveText('baz'))
     }
@@ -315,13 +315,13 @@ test('nested x-for',
         </div>
     `,
     ({ get }) => {
-        get('h1:nth-of-type(1) h2:nth-of-type(1)').should(beVisible())
-        get('h1:nth-of-type(1) h2:nth-of-type(2)').should(beVisible())
-        get('h1:nth-of-type(2) h2:nth-of-type(1)').should(notBeVisible())
+        get('h1:nth-of-type(1) h2:nth-of-type(1)').should(exist())
+        get('h1:nth-of-type(1) h2:nth-of-type(2)').should(exist())
+        get('h1:nth-of-type(2) h2:nth-of-type(1)').should(notExist())
         get('button').click()
-        get('h1:nth-of-type(1) h2:nth-of-type(1)').should(beVisible())
-        get('h1:nth-of-type(1) h2:nth-of-type(2)').should(beVisible())
-        get('h1:nth-of-type(2) h2:nth-of-type(1)').should(beVisible())
+        get('h1:nth-of-type(1) h2:nth-of-type(1)').should(exist())
+        get('h1:nth-of-type(1) h2:nth-of-type(2)').should(exist())
+        get('h1:nth-of-type(2) h2:nth-of-type(1)').should(exist())
     }
 )
 
@@ -538,7 +538,7 @@ test('x-for removed dom node does not evaluate child expressions after being rem
         get('span').should(haveText('lebowski'))
 
         /** Clicking button sets users=[] and thus x-for loop will remove all children.
-            If the sub-expression x-text="users[idx].name" is evaluated, the button click  
+            If the sub-expression x-text="users[idx].name" is evaluated, the button click
             will produce an error because users[idx] is no longer defined and the test will fail
         **/
         get('button').click()

+ 8 - 8
tests/cypress/integration/directives/x-if.spec.js

@@ -1,4 +1,4 @@
-import { beVisible, haveText, html, notBeVisible, test } from '../../utils'
+import { exist, haveText, html, notExist, test } from '../../utils'
 
 test('x-if',
     html`
@@ -11,11 +11,11 @@ test('x-if',
         </div>
     `,
     ({ get }) => {
-        get('h1').should(notBeVisible())
+        get('h1').should(notExist())
         get('button').click()
-        get('h1').should(beVisible())
+        get('h1').should(exist())
         get('button').click()
-        get('h1').should(notBeVisible())
+        get('h1').should(notExist())
     }
 )
 
@@ -65,11 +65,11 @@ test('x-if removed dom does not evaluate reactive expressions in dom tree',
     `,
     ({ get }) => {
         get('span').should(haveText('lebowski'))
-        
-        // Clicking button sets user=null and thus x-if="user" will evaluate to false. 
-        // If the sub-expression x-text="user.name" is evaluated, the button click  
+
+        // Clicking button sets user=null and thus x-if="user" will evaluate to false.
+        // If the sub-expression x-text="user.name" is evaluated, the button click
         // will produce an error because user is no longer defined and the test will fail
         get('button').click()
-        get('span').should('not.exist')
+        get('span').should(notExist())
     }
 )

+ 18 - 1
tests/cypress/integration/directives/x-show.spec.js

@@ -158,4 +158,21 @@ test('x-show executes consecutive state changes in correct order',
         get('button#enable').should(beVisible())
         get('button#disable').should(beHidden())
     }
-)
+)
+
+test('x-show toggles display: none; with the !important property when using the .important modifier while respecting other style attributes',
+    html`
+        <div x-data="{ show: true }">
+            <span x-show.important="show" style="color: blue;">thing</span>
+
+            <button x-on:click="show = false"></button>
+        </div>
+    `,
+    ({ get }) => {
+        get('span').should(beVisible())
+        get('span').should(haveAttribute('style', 'color: blue;'))
+        get('button').click()
+        get('span').should(beHidden())
+        get('span').should(haveAttribute('style', 'color: blue; display: none !important;'))
+    }
+)

+ 6 - 6
tests/cypress/integration/directives/x-teleport.spec.js

@@ -1,4 +1,4 @@
-import { beEqualTo, beVisible, haveText, html, notBeVisible, test } from '../../utils'
+import { exist, haveText, html, notExist, test } from '../../utils'
 
 test('can use a x-teleport',
     [html`
@@ -78,9 +78,9 @@ test('removing teleport source removes teleported target',
         <div id="b"></div>
     `],
     ({ get }) => {
-        get('#b h1').should(beVisible())
+        get('#b h1').should(exist())
         get('button').click()
-        get('#b h1').should(notBeVisible())
+        get('#b h1').should(notExist())
     },
 )
 
@@ -97,9 +97,9 @@ test('$refs inside teleport can be accessed outside',
         <div id="b"></div>
     `],
     ({ get }) => {
-        get('#b h1').should(beVisible())
+        get('#b h1').should(exist())
         get('button').click()
-        get('#b h1').should(notBeVisible())
+        get('#b h1').should(notExist())
     },
 )
 
@@ -114,7 +114,7 @@ test('$root is accessed outside teleport',
         <div id="b"></div>
     `],
     ({ get }) => {
-        get('#b h1').should(beVisible())
+        get('#b h1').should(exist())
         get('#b h1').should(haveText('a'))
     },
 )

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

@@ -342,3 +342,16 @@ test('can morph multiple nodes',
         get('p:nth-of-type(2)').should(haveText('2'))
     },
 )
+
+test('can morph table tr',
+    [html`
+        <table>
+            <tr><td>1</td></tr>
+        </table>
+    `],
+    ({ get }, reload, window, document) => {
+        let tr = document.querySelector('tr')
+        window.Alpine.morph(tr, '<tr><td>2</td></tr>')
+        get('td').should(haveText('2'))
+    },
+)

+ 16 - 16
tests/cypress/integration/plugins/persist.spec.js

@@ -1,4 +1,4 @@
-import { beEqualTo, beVisible, haveText, html, notBeVisible, test } from '../../utils'
+import { beEqualTo, exist, haveText, html, notExist, test } from '../../utils'
 
 test('can persist number',
     [html`
@@ -83,11 +83,11 @@ test('can persist boolean',
         </div>
     `],
     ({ get }, reload) => {
-        get('span').should(notBeVisible())
+        get('span').should(notExist())
         get('button').click()
-        get('span').should(beVisible())
+        get('span').should(exist())
         reload()
-        get('span').should(beVisible())
+        get('span').should(exist())
     },
 )
 
@@ -128,14 +128,14 @@ test('can persist using an alias',
         </div>
     `],
     ({ get }, reload) => {
-        get('span#one').should(notBeVisible())
-        get('span#two').should(notBeVisible())
+        get('span#one').should(notExist())
+        get('span#two').should(notExist())
         get('button').click()
-        get('span#one').should(notBeVisible())
-        get('span#two').should(beVisible())
+        get('span#one').should(notExist())
+        get('span#two').should(exist())
         reload()
-        get('span#one').should(notBeVisible())
-        get('span#two').should(beVisible())
+        get('span#one').should(notExist())
+        get('span#two').should(exist())
     },
 )
 
@@ -155,14 +155,14 @@ test('aliases do not affect other $persist calls',
         </div>
     `],
     ({ get }, reload) => {
-        get('span#one').should(notBeVisible())
-        get('span#two').should(notBeVisible())
+        get('span#one').should(notExist())
+        get('span#two').should(notExist())
         get('button').click()
-        get('span#one').should(notBeVisible())
-        get('span#two').should(beVisible())
+        get('span#one').should(notExist())
+        get('span#two').should(exist())
         reload()
-        get('span#one').should(notBeVisible())
-        get('span#two').should(beVisible())
+        get('span#one').should(notExist())
+        get('span#two').should(exist())
     },
 )
 

+ 204 - 0
tests/cypress/integration/plugins/ui/dialog.spec.js

@@ -0,0 +1,204 @@
+import { beVisible, haveAttribute, haveText, html, notBeVisible, notExist, test } from '../../../utils'
+
+test('has accessibility attributes',
+    [html`
+        <div x-data="{ open: false }">
+            <button @click="open = ! open">Toggle</button>
+
+            <article x-dialog x-model="open">
+                Dialog Contents!
+            </article>
+        </div>
+    `],
+    ({ get }) => {
+        get('article').should(haveAttribute('role', 'dialog'))
+        get('article').should(haveAttribute('aria-modal', 'true'))
+    },
+)
+
+test('works with x-model',
+    [html`
+        <div x-data="{ open: false }">
+            <button @click="open = ! open">Toggle</button>
+
+            <article x-dialog x-model="open">
+                Dialog Contents!
+            </article>
+        </div>
+    `],
+    ({ get }) => {
+        get('article').should(notBeVisible())
+        get('button').click()
+        get('article').should(beVisible())
+        get('button').click()
+        get('article').should(notBeVisible())
+    },
+)
+
+test('works with open prop and close event',
+    [html`
+        <div x-data="{ open: false }">
+            <button @click="open = ! open">Toggle</button>
+
+            <article x-dialog :open="open" @close="open = false">
+                Dialog Contents!
+            </article>
+        </div>
+    `],
+    ({ get }) => {
+        get('article').should(notBeVisible())
+        get('button').click()
+        get('article').should(beVisible())
+    },
+)
+
+test('works with static prop',
+    [html`
+        <div x-data="{ open: false }">
+            <button @click="open = ! open">Toggle</button>
+
+            <template x-if="open">
+                <article x-dialog static>
+                    Dialog Contents!
+                </article>
+            </template>
+        </div>
+    `],
+    ({ get }) => {
+        get('article').should(notExist())
+        get('button').click()
+        get('article').should(beVisible())
+    },
+)
+
+test('pressing escape closes modal',
+    [html`
+        <div x-data="{ open: false }">
+            <button @click="open = ! open">Toggle</button>
+
+            <article x-dialog x-model="open">
+                Dialog Contents!
+                <input type="text">
+            </article>
+        </div>
+    `],
+    ({ get }) => {
+        get('article').should(notBeVisible())
+        get('button').click()
+        get('article').should(beVisible())
+        get('input').type('{esc}')
+        get('article').should(notBeVisible())
+    },
+)
+
+test('x-dialog:panel allows for click away',
+    [html`
+        <div x-data="{ open: true }">
+            <h1>Click away on me</h1>
+
+            <article x-dialog x-model="open">
+                <div x-dialog:panel>
+                    Dialog Contents!
+                </div>
+            </article>
+        </div>
+    `],
+    ({ get }) => {
+        get('article').should(beVisible())
+        get('h1').click()
+        get('article').should(notBeVisible())
+    },
+)
+
+test('x-dialog:overlay closes dialog when clicked on',
+    [html`
+        <div x-data="{ open: true }">
+            <h1>Click away on me</h1>
+
+            <article x-dialog x-model="open">
+                <main x-dialog:overlay>
+                    Some Overlay
+                </main>
+
+                <div>
+                    Dialog Contents!
+                </div>
+            </article>
+        </div>
+    `],
+    ({ get }) => {
+        get('article').should(beVisible())
+        get('h1').click()
+        get('article').should(beVisible())
+        get('main').click()
+        get('article').should(notBeVisible())
+    },
+)
+
+test('x-dialog:title',
+    [html`
+        <article x-data x-dialog>
+            <h1 x-dialog:title>Dialog Title</h1>
+        </article>
+    `],
+    ({ get }) => {
+        get('article').should(haveAttribute('aria-labelledby', 'alpine-dialog-title-1'))
+        get('h1').should(haveAttribute('id', 'alpine-dialog-title-1'))
+    },
+)
+
+test('x-dialog:description',
+    [html`
+        <article x-data x-dialog>
+            <p x-dialog:description>Dialog Title</p>
+        </article>
+    `],
+    ({ get }) => {
+        get('article').should(haveAttribute('aria-describedby', 'alpine-dialog-description-1'))
+        get('p').should(haveAttribute('id', 'alpine-dialog-description-1'))
+    },
+)
+
+test('$modal.open exposes internal "open" state',
+    [html`
+        <div x-data="{ open: false }">
+            <button @click="open = ! open">Toggle</button>
+
+            <article x-dialog x-model="open">
+                Dialog Contents!
+                <h2 x-text="$dialog.open"></h2>
+            </article>
+        </div>
+    `],
+    ({ get }) => {
+        get('h2').should(haveText('false'))
+        get('button').click()
+        get('h2').should(haveText('true'))
+    },
+)
+
+test('works with x-teleport',
+    [html`
+        <div x-data="{ open: false }">
+            <button @click="open = ! open">Toggle</button>
+
+            <template x-teleport="body">
+                <article x-dialog x-model="open">
+                    Dialog Contents!
+                </article>
+            </template>
+        </div>
+    `],
+    ({ get }) => {
+        get('article').should(notBeVisible())
+        get('button').click()
+        get('article').should(beVisible())
+        get('button').click()
+        get('article').should(notBeVisible())
+    },
+)
+
+// Skipping these two tests as anything focus related seems to be flaky
+// with cypress, but fine in a real browser.
+// test('x-dialog traps focus'...
+// test('initial-focus prop'...

+ 140 - 0
tests/cypress/integration/plugins/ui/popover.spec.js

@@ -0,0 +1,140 @@
+import { beVisible, haveAttribute, html, notBeVisible, notHaveAttribute, test } from '../../../utils'
+
+test.skip('button toggles panel',
+    [html`
+        <div x-data x-popover>
+            <button x-popover:button>Toggle</button>
+
+            <ul x-popover:panel>
+                Dialog Contents!
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('ul').should(notBeVisible())
+        get('button').click()
+        get('ul').should(beVisible())
+        get('button').click()
+        get('ul').should(notBeVisible())
+    },
+)
+
+test.skip('has accessibility attributes',
+    [html`
+        <div x-data x-popover>
+            <button x-popover:button>Toggle</button>
+
+            <ul x-popover:panel>
+                Dialog Contents!
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('button').should(haveAttribute('aria-expanded', 'false'))
+        get('button').should(notHaveAttribute('aria-controls'))
+        get('button').click()
+        get('button').should(haveAttribute('aria-expanded', 'true'))
+        get('button').should(haveAttribute('aria-controls', 'alpine-popover-panel-1'))
+    },
+)
+
+test.skip('escape closes panel',
+    [html`
+        <div x-data x-popover>
+            <button x-popover:button>Toggle</button>
+
+            <ul x-popover:panel>
+                Dialog Contents!
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('ul').should(notBeVisible())
+        get('button').click()
+        get('ul').should(beVisible())
+        get('body').type('{esc}')
+        get('ul').should(notBeVisible())
+    },
+)
+
+test.skip('clicking outside closes panel',
+    [html`
+        <div>
+            <div x-data x-popover>
+                <button x-popover:button>Toggle</button>
+
+                <ul x-popover:panel>
+                    Dialog Contents!
+                </ul>
+            </div>
+
+            <h1>Click away to me</h1>
+        </div>
+    `],
+    ({ get }) => {
+        get('ul').should(notBeVisible())
+        get('button').click()
+        get('ul').should(beVisible())
+        get('h1').click()
+        get('ul').should(notBeVisible())
+    },
+)
+
+test.skip('focusing away closes panel',
+    [html`
+        <div>
+            <div x-data x-popover>
+                <button x-popover:button>Toggle</button>
+
+                <ul x-popover:panel>
+                    Dialog Contents!
+                </ul>
+            </div>
+
+            <a href="#">Focus Me</a>
+        </div>
+    `],
+    ({ get }) => {
+        get('ul').should(notBeVisible())
+        get('button').click()
+        get('ul').should(beVisible())
+        cy.focused().tab()
+        get('ul').should(notBeVisible())
+    },
+)
+
+test.skip('focusing away doesnt close panel if focusing inside a group',
+    [html`
+        <div x-data>
+            <div x-popover:group>
+                <div x-data x-popover id="1">
+                    <button x-popover:button>Toggle 1</button>
+                    <ul x-popover:panel>
+                        Dialog 1 Contents!
+                    </ul>
+                </div>
+                <div x-data x-popover id="2">
+                    <button x-popover:button>Toggle 2</button>
+                    <ul x-popover:panel>
+                        Dialog 2 Contents!
+                    </ul>
+                </div>
+            </div>
+
+            <a href="#">Focus Me</a>
+        </div>
+    `],
+    ({ get }) => {
+        get('#1 ul').should(notBeVisible())
+        get('#2 ul').should(notBeVisible())
+        get('#1 button').click()
+        get('#1 ul').should(beVisible())
+        get('#2 ul').should(notBeVisible())
+        cy.focused().tab()
+        get('#1 ul').should(beVisible())
+        get('#2 ul').should(notBeVisible())
+        cy.focused().tab()
+        get('#1 ul').should(notBeVisible())
+        get('#2 ul').should(notBeVisible())
+    },
+)

+ 85 - 0
tests/cypress/integration/plugins/ui/tabs.spec.js

@@ -0,0 +1,85 @@
+import { beVisible, haveFocus, html, notBeVisible, test } from '../../../utils'
+
+test.skip('can use tabs to toggle panels',
+    [html`
+        <div x-data x-tabs>
+            <div x-tabs:list>
+                <button x-tabs:tab button-1>First</button>
+                <button x-tabs:tab button-2>Second</button>
+            </div>
+
+            <div x-tabs:panels>
+                <div x-tabs:panel panel-1>First Panel</div>
+                <div x-tabs:panel panel-2>Second Panel</div>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('[panel-1]').should(beVisible())
+        get('[panel-2]').should(notBeVisible())
+        get('[button-2]').click()
+        get('[panel-1]').should(notBeVisible())
+        get('[panel-2]').should(beVisible())
+    },
+)
+
+test.skip('can use arrow keys to cycle through tabs',
+    [html`
+        <div x-data x-tabs>
+            <div x-tabs:list>
+                <button x-tabs:tab button-1>First</button>
+                <button x-tabs:tab button-2>Second</button>
+            </div>
+
+            <div x-tabs:panels>
+                <div x-tabs:panel panel-1>First Panel</div>
+                <div x-tabs:panel panel-2>Second Panel</div>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('[panel-1]').should(beVisible())
+        get('[panel-2]').should(notBeVisible())
+        get('[button-2]').click()
+        get('[button-2]').should(haveFocus())
+        get('[panel-1]').should(notBeVisible())
+        get('[panel-2]').should(beVisible())
+        get('[button-2]').type('{rightArrow}')
+        get('[button-1]').should(haveFocus())
+        get('[panel-1]').should(beVisible())
+        get('[panel-2]').should(notBeVisible())
+        get('[button-1]').type('{rightArrow}')
+        get('[button-2]').should(haveFocus())
+        get('[panel-1]').should(notBeVisible())
+        get('[panel-2]').should(beVisible())
+    },
+)
+
+test.skip('cant tab through tabs, can only use arrows',
+    [html`
+        <div>
+            <button button-1>first focusable</button>
+            <div x-data x-tabs>
+                <div x-tabs:list>
+                    <button x-tabs:tab button-2>First</button>
+                    <button x-tabs:tab button-3>Second</button>
+                </div>
+                <div x-tabs:panels>
+                    <div x-tabs:panel panel-1>First Panel</div>
+                    <div x-tabs:panel panel-2>Second Panel</div>
+                </div>
+            </div>
+            <button button-4>first focusable</button>
+        </div>
+    `],
+    ({ get }) => {
+        get('[button-1]').click()
+        get('[button-1]').should(haveFocus())
+        get('[button-1]').tab()
+        get('[button-2]').should(haveFocus())
+        get('[button-2]').tab()
+        get('[panel-1]').should(haveFocus())
+        get('[panel-1]').tab()
+        get('[button-4]').should(haveFocus())
+    },
+)

+ 1 - 0
tests/cypress/spec.html

@@ -13,6 +13,7 @@
     <script src="/../../packages/intersect/dist/cdn.js"></script>
     <script src="/../../packages/collapse/dist/cdn.js"></script>
     <script src="/../../packages/mask/dist/cdn.js"></script>
+    <script src="/../../packages/ui/dist/cdn.js"></script>
     <script>
         let root = document.querySelector('#root')
 

+ 11 - 1
tests/cypress/utils.js

@@ -17,6 +17,12 @@ test.only = (name, template, callback, handleExpectedErrors = false) => {
     })
 }
 
+test.skip = (name, template, callback, handleExpectedErrors = false) => {
+    it.skip(name, () => {
+        injectHtmlAndBootAlpine(cy, template, callback, undefined, handleExpectedErrors)
+    })
+}
+
 test.retry = (count) => (name, template, callback, handleExpectedErrors = false) => {
     it(name, {
         retries: {
@@ -77,7 +83,7 @@ function injectHtmlAndBootAlpine(cy, templateAndPotentiallyScripts, callback, pa
 
         cy.window().then(window => {
             callback(cy, reload, window, window.document)
-        }) 
+        })
     })
 }
 
@@ -105,6 +111,10 @@ export let beVisible = () => el => expect(el).to.be.visible
 
 export let notBeVisible = () => el => expect(el).not.to.be.visible
 
+export let exist = () => el => expect(el).to.exist
+
+export let notExist = () => el => expect(el).not.to.exist
+
 export let beHidden = () => el => expect(el).to.be.hidden
 
 export let haveClasses = classes => el => classes.forEach(aClass => expect(el).to.have.class(aClass))