Caleb Porzio 3 vuotta sitten
vanhempi
commit
ce241bce87

+ 83 - 7
index.html

@@ -5,15 +5,91 @@
     <script src="./packages/persist/dist/cdn.js"></script>
     <script src="./packages/focus/dist/cdn.js"></script>
     <script src="./packages/mask/dist/cdn.js"></script>
+    <script src="./packages/ui/dist/cdn.js"></script>
     <script src="./packages/alpinejs/dist/cdn.js" defer></script>
     <!-- <script src="https://unpkg.com/alpinejs@3.0.0/dist/cdn.min.js" defer></script> -->
+    <script src="//cdn.tailwindcss.com"></script>
+    <script src="https://unpkg.com/@popperjs/core@2"></script>
 
-    <!-- Play around. -->
-    <div x-data="{ open: false }">
-        <button @click="open = !open">Toggle</button>
+    <main class="flex-1 overflow-auto bg-gray-50 h-screen">
+        <div class="flex justify-center items-center space-x-12 p-12">
+            <button>Previous</button>
+            <nav
+                x-data="{ links: ['First', 'Second', 'Third', 'Fourth'] }"
+                x-popover:group
+                aria-label="Mythical University"
+                class="flex space-x-3"
+            >
+                <div x-popover class="relative">
+                    <div
+                        x-popover:overlay
+                        x-transition:enter="transition ease-out duration-300 transform"
+                        x-transition:enter-start="opacity-0"
+                        x-transition:enter-end="opacity-100"
+                        x-transition:leave="transition ease-in duration-300 transform"
+                        x-transition:leave-start="opacity-100"
+                        x-transition:leave-end="opacity-0"
+                        class="bg-opacity-75 bg-gray-500 fixed inset-0 z-20"
+                    ></div>
+                    <button
+                        x-popover:button
+                        class="px-3 py-2 bg-gray-300 border-2 border-transparent focus:outline-none focus:border-blue-900 relative z-30"
+                    >Normal</button>
+                    <div x-popover:panel class="absolute flex flex-col w-64 bg-gray-100 border-2 border-blue-900 z-30">
+                        <template x-for="(link, i) of links">
+                            <a :hidden="i === 2" href="/" class="px-3 py-2 border-2 border-transparent hover:bg-gray-200 focus:bg-gray-200 focus:outline-none focus:border-blue-900">
+                                Normal - <span x-text="link"></span>
+                            </a>
+                        </template>
+                    </div>
+                </div>
+                <div x-popover class="relative">
+                    <button x-popover:button class="px-3 py-2 bg-gray-300 border-2 border-transparent focus:outline-none focus:border-blue-900">Focus</button>
+                    <div x-popover:panel :focus="true" class="absolute flex flex-col w-64 bg-gray-100 border-2 border-blue-900">
+                        <template x-for="(link, i) of links">
+                            <a href="/" class="px-3 py-2 border-2 border-transparent hover:bg-gray-200 focus:bg-gray-200 focus:outline-none focus:border-blue-900">
+                                Focus - <span x-text="link"></span>
+                            </a>
+                        </template>
+                    </div>
+                </div>
 
-        <span x-show="open">
-            Content...
-        </span>
-    </div>
+                <div
+                    x-popover
+                    class="relative"
+                    x-init="$nextTick(() => Popper.createPopper($refs.trigger1, $refs.container1, { placement: 'bottom-start', strategy: 'fixed' }))"
+                >
+                    <button x-popover:button x-ref="trigger1" class="px-3 py-2 bg-gray-300 border-2 border-transparent focus:outline-none focus:border-blue-900">Portal</button>
+                    <template x-teleport="body">
+                        <div x-popover:panel x-ref="container1" class="flex flex-col w-64 bg-gray-100 border-2 border-blue-900">
+                            <template x-for="(link, i) of links">
+                                <a href="/" class="px-3 py-2 border-2 border-transparent hover:bg-gray-200 focus:bg-gray-200 focus:outline-none focus:border-blue-900">
+                                    Portal - <span x-text="link"></span>
+                                </a>
+                            </template>
+                        </div>
+                    </template>
+                </div>
+
+                <div
+                    x-popover
+                    class="relative"
+                    x-init="$nextTick(() => Popper.createPopper($refs.trigger2, $refs.container2, { placement: 'bottom-start', strategy: 'fixed' }))"
+                >
+                    <button x-popover:button x-ref="trigger2" class="px-3 py-2 bg-gray-300 border-2 border-transparent focus:outline-none focus:border-blue-900">Focus in portal</button>
+
+                    <template x-teleport="body">
+                        <div x-popover:panel :focus="true" x-ref="container2" class="flex flex-col w-64 bg-gray-100 border-2 border-blue-900">
+                            <template x-for="(link, i) of links">
+                                <a href="/" class="px-3 py-2 border-2 border-transparent hover:bg-gray-200 focus:bg-gray-200 focus:outline-none focus:border-blue-900">
+                                    Focus in Portal - <span x-text="link"></span>
+                                </a>
+                            </template>
+                        </div>
+                    </template>
+                </div>
+            </nav>
+            <button>Next</button>
+        </div>
+    </main>
 </html>

+ 37 - 2
packages/alpinejs/src/binds.js

@@ -1,8 +1,15 @@
+import { attributesOnly, directives } from "./directives"
 
 let binds = {}
 
-export function bind(name, object) {
-    binds[name] = typeof object !== 'function' ? () => object : object
+export function bind(name, bindings) {
+    let getBindings = typeof bindings !== 'function' ? () => bindings : bindings
+
+    if (name instanceof Element) {
+        applyBindingsObject(name, getBindings())
+    } else {
+        binds[name] = getBindings
+    }
 }
 
 export function injectBindingProviders(obj) {
@@ -18,3 +25,31 @@ export function injectBindingProviders(obj) {
 
     return obj
 }
+
+export function applyBindingsObject(el, obj, original) {
+    let cleanupRunners = []
+
+    while (cleanupRunners.length) cleanupRunners.pop()()
+
+    let attributes = Object.entries(obj).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()
+    })
+}

+ 12 - 37
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,40 +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

+ 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.2",
+    "description": "Alpine UI components",
     "author": "Caleb Porzio",
     "license": "MIT",
     "main": "dist/module.cjs.js",

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

@@ -0,0 +1,88 @@
+
+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
+            }
+        }
+    })
+}
+
+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() },
+    })
+}
+
+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') },
+    })
+}
+

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

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

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

@@ -0,0 +1,184 @@
+
+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) {
+                e.preventDefault()
+                e.stopPropagation()
+                this.$focus.within(this.$data.__panelEl).first()
+            }
+        },
+        '@keyup.tab'(e) {
+            if (this.$data.__isOpen) {
+                // Check if the last focused element was "after" this one
+                let lastEl = this.$focus.previouslyFocused()
+
+                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.
+                    && 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 }
+    })
+}

+ 1 - 0
scripts/build.js

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

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

@@ -0,0 +1,204 @@
+import { beVisible, haveAttribute, haveText, html, notBeVisible, 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(notBeVisible())
+        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'...

+ 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')