Browse Source

Merge branch 'main' into add-navigate

Caleb Porzio 2 years ago
parent
commit
2199fb4b46
44 changed files with 1617 additions and 191 deletions
  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",
     "name": "alpinejs",
-    "version": "3.10.2",
+    "version": "3.10.3",
     "description": "The rugged, minimal JavaScript framework",
     "description": "The rugged, minimal JavaScript framework",
     "author": "Caleb Porzio",
     "author": "Caleb Porzio",
     "license": "MIT",
     "license": "MIT",

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

@@ -26,6 +26,12 @@ export function injectBindingProviders(obj) {
     return 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) {
 export function applyBindingsObject(el, obj, original) {
     let cleanupRunners = []
     let cleanupRunners = []
 
 

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

@@ -20,9 +20,31 @@ export function directive(name, callback) {
 }
 }
 
 
 export function directives(el, attributes, originalAttributeOverride) {
 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 transformedAttributeMap = {}
 
 
-    let directives = Array.from(attributes)
+    let directives = attributes
         .map(toTransformedAttributes((newName, oldName) => transformedAttributeMap[newName] = oldName))
         .map(toTransformedAttributes((newName, oldName) => transformedAttributeMap[newName] = oldName))
         .filter(outNonAlpineAttributes)
         .filter(outNonAlpineAttributes)
         .map(toParsedDirectives(transformedAttributeMap, originalAttributeOverride))
         .map(toParsedDirectives(transformedAttributeMap, originalAttributeOverride))
@@ -178,7 +200,6 @@ let directiveOrder = [
     'if',
     'if',
     DEFAULT,
     DEFAULT,
     'teleport',
     'teleport',
-    'element',
 ]
 ]
 
 
 function byPriority(a, b) {
 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 { evaluateLater } from '../evaluator'
 import { mutateDom } from '../mutation'
 import { mutateDom } from '../mutation'
 import bind from '../utils/bind'
 import bind from '../utils/bind'
-import { injectBindingProviders } from '../binds'
+import { applyBindingsObject, injectBindingProviders } from '../binds'
 
 
 mapAttributes(startingWith(':', into(prefix('bind:'))))
 mapAttributes(startingWith(':', into(prefix('bind:'))))
 
 
 directive('bind', (el, { value, modifiers, expression, original }, { effect }) => {
 directive('bind', (el, { value, modifiers, expression, original }, { effect }) => {
     if (! value) {
     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)
     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) {
 function storeKeyForXFor(el, expression) {
     el._x_keyExpression = 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
     // We're going to set this function on the element directly so that
     // other plugins like "Collapse" can overwrite them with their own logic.
     // other plugins like "Collapse" can overwrite them with their own logic.
     if (! el._x_doHide) el._x_doHide = () => {
     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 = () => {
     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({})
     if (! el._x_bindings) el._x_bindings = reactive({})
 
 
     el._x_bindings[name] = value
     el._x_bindings[name] = value
-   
+
     name = modifiers.includes('camel') ? camelCase(name) : name
     name = modifiers.includes('camel') ? camelCase(name) : name
 
 
     switch (name) {
     switch (name) {
@@ -129,21 +129,21 @@ function attributeShouldntBePreservedIfFalsy(name) {
 }
 }
 
 
 export function getBinding(el, name, fallback) {
 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 (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)
     let attr = el.getAttribute(name)
 
 
     // Nothing bound:
     // Nothing bound:
     if (attr === null) return typeof fallback === 'function' ? fallback() : fallback
     if (attr === null) return typeof fallback === 'function' ? fallback() : fallback
-   
+
     if (isBooleanAttr(name)) {
     if (isBooleanAttr(name)) {
         return !! [name, 'true'].includes(attr)
         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
     if (attr === '') return true
-   
+
     return attr
     return attr
 }
 }

+ 1 - 1
packages/collapse/package.json

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

+ 1 - 1
packages/docs/package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "@alpinejs/docs",
     "name": "@alpinejs/docs",
-    "version": "3.10.2-revision.2",
+    "version": "3.10.3-revision.1",
     "description": "The documentation for Alpine",
     "description": "The documentation for Alpine",
     "author": "Caleb Porzio",
     "author": "Caleb Porzio",
     "license": "MIT"
     "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                       |
 | `.space`                    | Space                       |
 | `.ctrl`                     | Ctrl                        |
 | `.ctrl`                     | Ctrl                        |
 | `.cmd`                      | Cmd                         |
 | `.cmd`                      | Cmd                         |
-| `.meta`                     | Cmd on Mac, Ctrl on Windows |
+| `.meta`                     | Cmd on Mac, Windows key on Windows |
 | `.alt`                      | Alt                         |
 | `.alt`                      | Alt                         |
 | `.up` `.down` `.left` `.right` | Up/Down/Left/Right arrows   |
 | `.up` `.down` `.left` `.right` | Up/Down/Left/Right arrows   |
 | `.escape`                   | Escape                      |
 | `.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>
 </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.
 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
 ```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.
 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",
     "name": "@alpinejs/focus",
-    "version": "3.10.2",
+    "version": "3.10.3",
     "description": "Manage focus within a page",
     "description": "Manage focus within a page",
     "author": "Caleb Porzio",
     "author": "Caleb Porzio",
     "license": "MIT",
     "license": "MIT",

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

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

+ 1 - 1
packages/intersect/package.json

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

+ 1 - 1
packages/mask/package.json

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

+ 1 - 1
packages/morph/package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "@alpinejs/morph",
     "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",
     "description": "Diff and patch a block of HTML on a page with an HTML template",
     "author": "Caleb Porzio",
     "author": "Caleb Porzio",
     "license": "MIT",
     "license": "MIT",

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

@@ -57,7 +57,9 @@ export function dom(el) {
 }
 }
 
 
 export function createElement(html) {
 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) {
 export function textOrComment(el) {

+ 1 - 1
packages/persist/package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "@alpinejs/persist",
     "name": "@alpinejs/persist",
-    "version": "3.10.2",
+    "version": "3.10.3",
     "description": "Persist Alpine data across page loads",
     "description": "Persist Alpine data across page loads",
     "author": "Caleb Porzio",
     "author": "Caleb Porzio",
     "license": "MIT",
     "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",
     "author": "Caleb Porzio",
     "license": "MIT",
     "license": "MIT",
     "main": "dist/module.cjs.js",
     "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',
     'focus',
     'mask',
     'mask',
     'navigate',
     'navigate',
+    'ui',
 ]).forEach(package => {
 ]).forEach(package => {
     if (! fs.existsSync(`./packages/${package}/dist`)) {
     if (! fs.existsSync(`./packages/${package}/dist`)) {
         fs.mkdirSync(`./packages/${package}/dist`, 0744);
         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'))
     ({ 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>
         </div>
     `,
     `,
     ({ get }) => {
     ({ 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',
 test('renders loops with x-for',
     html`
     html`
@@ -12,7 +12,7 @@ test('renders loops with x-for',
     `,
     `,
     ({ get }) => {
     ({ get }) => {
         get('span:nth-of-type(1)').should(haveText('foo'))
         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('button').click()
         get('span:nth-of-type(1)').should(haveText('foo'))
         get('span:nth-of-type(1)').should(haveText('foo'))
         get('span:nth-of-type(2)').should(haveText('bar'))
         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 }) => {
         get('#1 span:nth-of-type(1)').should(haveText('foo'))
         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(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('button').click()
         get('#1 span:nth-of-type(1)').should(haveText('foo'))
         get('#1 span:nth-of-type(1)').should(haveText('foo'))
         get('#1 span:nth-of-type(2)').should(haveText('bar'))
         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>
         </div>
     `,
     `,
     ({ get }) => {
     ({ get }) => {
-        get('span').should(beVisible())
+        get('span').should(exist())
         get('button').click()
         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>
         </div>
     `,
     `,
     ({ get }) => {
     ({ 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('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>
         </div>
     `,
     `,
     ({ get }) => {
     ({ get }) => {
-        get('span').should(beVisible())
+        get('span').should(exist())
         get('h1').should(haveText('first'))
         get('h1').should(haveText('first'))
         get('h2').should(haveText('bar'))
         get('h2').should(haveText('bar'))
         get('button').click()
         get('button').click()
-        get('span').should(beVisible())
+        get('span').should(exist())
         get('h1').should(haveText('first'))
         get('h1').should(haveText('first'))
         get('h2').should(haveText('baz'))
         get('h2').should(haveText('baz'))
     }
     }
@@ -315,13 +315,13 @@ test('nested x-for',
         </div>
         </div>
     `,
     `,
     ({ get }) => {
     ({ 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('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'))
         get('span').should(haveText('lebowski'))
 
 
         /** Clicking button sets users=[] and thus x-for loop will remove all children.
         /** 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
             will produce an error because users[idx] is no longer defined and the test will fail
         **/
         **/
         get('button').click()
         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',
 test('x-if',
     html`
     html`
@@ -11,11 +11,11 @@ test('x-if',
         </div>
         </div>
     `,
     `,
     ({ get }) => {
     ({ get }) => {
-        get('h1').should(notBeVisible())
+        get('h1').should(notExist())
         get('button').click()
         get('button').click()
-        get('h1').should(beVisible())
+        get('h1').should(exist())
         get('button').click()
         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 }) => {
         get('span').should(haveText('lebowski'))
         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
         // will produce an error because user is no longer defined and the test will fail
         get('button').click()
         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#enable').should(beVisible())
         get('button#disable').should(beHidden())
         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',
 test('can use a x-teleport',
     [html`
     [html`
@@ -78,9 +78,9 @@ test('removing teleport source removes teleported target',
         <div id="b"></div>
         <div id="b"></div>
     `],
     `],
     ({ get }) => {
     ({ get }) => {
-        get('#b h1').should(beVisible())
+        get('#b h1').should(exist())
         get('button').click()
         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>
         <div id="b"></div>
     `],
     `],
     ({ get }) => {
     ({ get }) => {
-        get('#b h1').should(beVisible())
+        get('#b h1').should(exist())
         get('button').click()
         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>
         <div id="b"></div>
     `],
     `],
     ({ get }) => {
     ({ get }) => {
-        get('#b h1').should(beVisible())
+        get('#b h1').should(exist())
         get('#b h1').should(haveText('a'))
         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'))
         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',
 test('can persist number',
     [html`
     [html`
@@ -83,11 +83,11 @@ test('can persist boolean',
         </div>
         </div>
     `],
     `],
     ({ get }, reload) => {
     ({ get }, reload) => {
-        get('span').should(notBeVisible())
+        get('span').should(notExist())
         get('button').click()
         get('button').click()
-        get('span').should(beVisible())
+        get('span').should(exist())
         reload()
         reload()
-        get('span').should(beVisible())
+        get('span').should(exist())
     },
     },
 )
 )
 
 
@@ -128,14 +128,14 @@ test('can persist using an alias',
         </div>
         </div>
     `],
     `],
     ({ get }, reload) => {
     ({ 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('button').click()
-        get('span#one').should(notBeVisible())
-        get('span#two').should(beVisible())
+        get('span#one').should(notExist())
+        get('span#two').should(exist())
         reload()
         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>
         </div>
     `],
     `],
     ({ get }, reload) => {
     ({ 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('button').click()
-        get('span#one').should(notBeVisible())
-        get('span#two').should(beVisible())
+        get('span#one').should(notExist())
+        get('span#two').should(exist())
         reload()
         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/intersect/dist/cdn.js"></script>
     <script src="/../../packages/collapse/dist/cdn.js"></script>
     <script src="/../../packages/collapse/dist/cdn.js"></script>
     <script src="/../../packages/mask/dist/cdn.js"></script>
     <script src="/../../packages/mask/dist/cdn.js"></script>
+    <script src="/../../packages/ui/dist/cdn.js"></script>
     <script>
     <script>
         let root = document.querySelector('#root')
         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) => {
 test.retry = (count) => (name, template, callback, handleExpectedErrors = false) => {
     it(name, {
     it(name, {
         retries: {
         retries: {
@@ -77,7 +83,7 @@ function injectHtmlAndBootAlpine(cy, templateAndPotentiallyScripts, callback, pa
 
 
         cy.window().then(window => {
         cy.window().then(window => {
             callback(cy, reload, window, window.document)
             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 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 beHidden = () => el => expect(el).to.be.hidden
 
 
 export let haveClasses = classes => el => classes.forEach(aClass => expect(el).to.have.class(aClass))
 export let haveClasses = classes => el => classes.forEach(aClass => expect(el).to.have.class(aClass))