1
0
Эх сурвалжийг харах

Merge branch 'main' into docs/cdn2

Josh Hanley 2 жил өмнө
parent
commit
21e26ce8f8
65 өөрчлөгдсөн 6046 нэмэгдсэн , 229 устгасан
  1. 164 6
      index.html
  2. 7 1
      packages/alpinejs/package.json
  3. 43 2
      packages/alpinejs/src/binds.js
  4. 33 2
      packages/alpinejs/src/directives.js
  5. 15 38
      packages/alpinejs/src/directives/x-bind.js
  6. 12 0
      packages/alpinejs/src/directives/x-model.js
  7. 7 7
      packages/alpinejs/src/utils/bind.js
  8. 7 1
      packages/collapse/package.json
  9. 2 2
      packages/collapse/src/index.js
  10. 1 1
      packages/docs/package.json
  11. 24 2
      packages/docs/src/en/directives/for.md
  12. 1 1
      packages/docs/src/en/directives/on.md
  13. 1 1
      packages/docs/src/en/essentials/installation.md
  14. 22 6
      packages/docs/src/en/plugins/mask.md
  15. 6 0
      packages/docs/src/en/ui.md
  16. 7 1
      packages/focus/package.json
  17. 1 1
      packages/focus/src/index.js
  18. 6 0
      packages/history/package.json
  19. 7 1
      packages/intersect/package.json
  20. 7 1
      packages/mask/package.json
  21. 8 9
      packages/mask/src/index.js
  22. 7 1
      packages/morph/package.json
  23. 7 1
      packages/persist/package.json
  24. 0 5
      packages/portal/builds/cdn.js
  25. 0 3
      packages/portal/builds/module.js
  26. 0 11
      packages/portal/package.json
  27. 0 62
      packages/portal/src/index.js
  28. 5 0
      packages/ui/builds/cdn.js
  29. 3 0
      packages/ui/builds/module.js
  30. 18 0
      packages/ui/demo/index.html
  31. 105 0
      packages/ui/demo/listbox/data-driven.html
  32. 233 0
      packages/ui/demo/listbox/index.html
  33. 105 0
      packages/ui/demo/listbox/multiple.html
  34. 17 0
      packages/ui/package.json
  35. 550 0
      packages/ui/src/combobox.js
  36. 96 0
      packages/ui/src/dialog.js
  37. 80 0
      packages/ui/src/disclosure.js
  38. 19 0
      packages/ui/src/index.js
  39. 460 0
      packages/ui/src/list-context.js
  40. 244 0
      packages/ui/src/listbox.js
  41. 229 0
      packages/ui/src/menu.js
  42. 209 0
      packages/ui/src/popover.js
  43. 220 0
      packages/ui/src/radio.js
  44. 116 0
      packages/ui/src/switch.js
  45. 141 0
      packages/ui/src/tabs.js
  46. 1 0
      scripts/build.js
  47. 15 0
      tests/cypress/integration/custom-bind.spec.js
  48. 12 10
      tests/cypress/integration/directives/x-bind.spec.js
  49. 21 21
      tests/cypress/integration/directives/x-for.spec.js
  50. 8 8
      tests/cypress/integration/directives/x-if.spec.js
  51. 19 0
      tests/cypress/integration/directives/x-model.spec.js
  52. 6 6
      tests/cypress/integration/directives/x-teleport.spec.js
  53. 14 0
      tests/cypress/integration/plugins/mask.spec.js
  54. 16 16
      tests/cypress/integration/plugins/persist.spec.js
  55. 204 0
      tests/cypress/integration/plugins/ui/dialog.spec.js
  56. 102 0
      tests/cypress/integration/plugins/ui/disclosure.spec.js
  57. 868 0
      tests/cypress/integration/plugins/ui/listbox.spec.js
  58. 392 0
      tests/cypress/integration/plugins/ui/menu.spec.js
  59. 190 0
      tests/cypress/integration/plugins/ui/popover.spec.js
  60. 513 0
      tests/cypress/integration/plugins/ui/radio.spec.js
  61. 151 0
      tests/cypress/integration/plugins/ui/switch.spec.js
  62. 225 0
      tests/cypress/integration/plugins/ui/tabs.spec.js
  63. 1 0
      tests/cypress/spec.html
  64. 11 1
      tests/cypress/utils.js
  65. 32 1
      tests/jest/mask.spec.js

+ 164 - 6
index.html

@@ -5,15 +5,173 @@
     <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" defer></script>
     <script src="./packages/alpinejs/dist/cdn.js" defer></script>
+    <script src="//cdn.tailwindcss.com"></script>
     <!-- <script src="https://cdn.jsdelivr.net/npm/alpinejs@3.0.0/dist/cdn.min.js" defer></script> -->
 
-    <!-- Play around. -->
-    <div x-data="{ open: false }">
-        <button @click="open = !open">Toggle</button>
+    <!-- <div x-data="{ value: null }">
+        Value: <span x-text="value"></span>
 
-        <span x-show="open">
-            Content...
-        </span>
+        <button @click="value = 'bar'">Change value</button>
+
+        <div x-listbox x-model="value">
+            <button x-listbox:button>toggle</button>
+
+            <ul x-listbox:options>
+                <li x-listbox:option value="foo" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Foo</li>
+                <li x-listbox:option value="bar" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Bar</li>
+                <li x-listbox:option value="baz" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Baz</li>
+            </ul>
+        </div>
+    </div> -->
+
+    <!-- <div
+    x-data="{ selected: undefined, people: [
+        { id: 1, name: 'Wade Cooper', foo: { bar: 'baz' } },
+        { id: 2, name: 'Arlene Mccoy' },
+        { id: 3, name: 'Devon Webb' },
+        { id: 4, name: 'Tom Cook' },
+        { id: 5, name: 'Tanya Fox', disabled: true },
+        { id: 6, name: 'Hellen Schmidt' },
+        { id: 7, name: 'Caroline Schultz' },
+        { id: 8, name: 'Mason Heaney' },
+        { id: 9, name: 'Claudie Smitham' },
+        { id: 10, name: 'Emil Schaefer' },
+        ]}"
+        class="flex justify-center w-screen h-full p-12 bg-gray-50"
+    >
+        <button @click="selected = people[1]">Change value</button>
+
+        <button @click="
+            people.sort((a, b) => a.name > b.name ? 1 : -1)
+        ">Reorder</button>
+
+        <button @click="
+            people = people.filter(i => i.name !== 'Arlene Mccoy')
+        ">Destroy item</button>
+
+        <div class="w-full max-w-xs mx-auto">
+            <div x-listbox name="something" x-model="selected" class="space-y-1">
+                <label x-listbox:label class="block text-sm font-medium leading-5 text-gray-700 mb-1">
+                    Assigned to
+                </label>
+
+                <div class="relative">
+                    <span class="inline-block w-full rounded-md shadow-sm">
+                        <button x-listbox:button class="relative w-full py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
+                            <span class="block truncate" x-text="selected ? selected.name : 'Select Person'"></span>
+                            <span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
+                                <svg class="w-5 h-5 text-gray-400" viewBox="0 0 20 20" fill="none" stroke="currentColor">
+                                    <path d="M7 7l3-3 3 3m0 6l-3 3-3-3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
+                                </svg>
+                            </span>
+                        </button>
+                    </span>
+
+                    <div class="absolute w-full mt-1 bg-white rounded-md shadow-lg">
+                        <ul x-listbox:options class="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5">
+                            <template x-for="person in people" :key="person.id">
+                                <li
+                                    x-listbox:option :value="person"
+                                    class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
+                                    :disabled="person.disabled"
+                                    :class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900', person.disabled && 'bg-gray-50 text-gray-300'].filter(Boolean)"
+                                >
+                                    <span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'" x-text="person.name"></span>
+
+                                    <span
+                                        x-show="$listboxOption.isSelected"
+                                        class="absolute inset-y-0 right-0 flex items-center pr-4"
+                                        :class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
+                                    >
+                                        <svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
+                                            <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
+                                        </svg>
+                                    </span>
+                                </li>
+                            </template>
+                        </ul>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div> -->
+
+
+    <!-- MULTIPLE: -->
+    <div
+        x-data="{ selected: [], people: [
+            { id: 1, name: 'Wade Cooper' },
+            { id: 2, name: 'Arlene Mccoy' },
+            { id: 3, name: 'Devon Webb' },
+            { id: 4, name: 'Tom Cook' },
+            { id: 5, name: 'Tanya Fox', disabled: true },
+            { id: 6, name: 'Hellen Schmidt' },
+            { id: 7, name: 'Caroline Schultz' },
+            { id: 8, name: 'Mason Heaney' },
+            { id: 9, name: 'Claudie Smitham' },
+            { id: 10, name: 'Emil Schaefer' },
+        ]}"
+        class="flex justify-center w-screen h-full p-12 bg-gray-50"
+    >
+        <div class="w-full max-w-xs mx-auto">
+
+            <button @click="selected.push(people[1])">Change value</button>
+
+            <button @click="
+                people.sort((a, b) => a.name > b.name ? 1 : -1)
+            ">Reorder</button>
+
+            <button @click="
+                people = people.filter(i => i.name !== 'Arlene Mccoy')
+            ">Destroy item</button>
+
+            <div x-listbox name="people" multiple class="space-y-1">
+                <label x-listbox:label class="block text-sm font-medium leading-5 text-gray-700 mb-1">
+                    Assigned to
+                </label>
+
+                <div class="relative">
+                    <span class="inline-block w-full rounded-md shadow-sm">
+                        <button x-listbox:button class="relative w-full py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
+                            <span class="block truncate" x-text="selected.length > 0 ? selected.map(i => i.name).join(', ') : 'Select Person'"></span>
+                            <span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
+                                <svg class="w-5 h-5 text-gray-400" viewBox="0 0 20 20" fill="none" stroke="currentColor">
+                                    <path d="M7 7l3-3 3 3m0 6l-3 3-3-3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
+                                </svg>
+                            </span>
+                        </button>
+                    </span>
+
+                    <div class="absolute w-full mt-1 bg-white rounded-md shadow-lg">
+                        <div x-show="$listbox.isOpen">
+                            wrapper
+                            <ul x-listbox:options static class="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5">
+                                <template x-for="person in people" :key="person.id">
+                                    <li
+                                        x-listbox:option :value="person"
+                                        class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
+                                        :disabled="person.disabled"
+                                        :class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900', person.disabled && 'bg-gray-50 text-gray-300'].filter(Boolean)"
+                                        >
+                                        <span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'" x-text="person.name"></span>
+                                        <span
+                                            x-show="$listboxOption.isSelected"
+                                            class="absolute inset-y-0 right-0 flex items-center pr-4"
+                                            :class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
+                                        >
+                                            <svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
+                                                <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
+                                            </svg>
+                                        </span>
+                                    </li>
+                                </template>
+                            </ul>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
     </div>
 </html>

+ 7 - 1
packages/alpinejs/package.json

@@ -1,7 +1,13 @@
 {
     "name": "alpinejs",
-    "version": "3.10.2",
+    "version": "3.10.5",
     "description": "The rugged, minimal JavaScript framework",
+    "homepage": "https://alpinejs.dev",
+    "repository": {
+        "type": "git",
+        "url": "https://github.com/alpinejs/alpine.git",
+        "directory": "packages/alpinejs"
+    },
     "author": "Caleb Porzio",
     "license": "MIT",
     "main": "dist/module.cjs.js",

+ 43 - 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,37 @@ export function injectBindingProviders(obj) {
 
     return obj
 }
+
+export function addVirtualBindings(el, bindings) {
+    let getBindings = typeof bindings !== 'function' ? () => bindings : bindings
+
+    el._x_virtualDirectives = getBindings()
+}
+
+export function applyBindingsObject(el, obj, original) {
+    let cleanupRunners = []
+
+    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()
+    })
+}

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

@@ -20,9 +20,31 @@ export function directive(name, callback) {
 }
 
 export function directives(el, attributes, originalAttributeOverride) {
+    attributes = Array.from(attributes)
+
+    if (el._x_virtualDirectives) {
+        let vAttributes = Object.entries(el._x_virtualDirectives).map(([name, value]) => ({ name, value }))
+
+        let staticAttributes = attributesOnly(vAttributes)
+
+        // Handle binding normal HTML attributes (non-Alpine directives).
+        vAttributes = vAttributes.map(attribute => {
+            if (staticAttributes.find(attr => attr.name === attribute.name)) {
+                return {
+                    name: `x-bind:${attribute.name}`,
+                    value: `"${attribute.value}"`,
+                }
+            }
+
+            return attribute
+        })
+
+        attributes = attributes.concat(vAttributes)
+    }
+
     let transformedAttributeMap = {}
 
-    let directives = Array.from(attributes)
+    let directives = attributes
         .map(toTransformedAttributes((newName, oldName) => transformedAttributeMap[newName] = oldName))
         .filter(outNonAlpineAttributes)
         .map(toParsedDirectives(transformedAttributeMap, originalAttributeOverride))
@@ -167,6 +189,16 @@ let directiveOrder = [
     'ref',
     'data',
     'id',
+    // @todo: provide better directive ordering mechanisms so
+    // that I don't have to manually add things like "tabs"
+    // to the order list...
+    'radio',
+    'tabs',
+    'switch',
+    'disclosure',
+    'menu',
+    'listbox',
+    'combobox',
     'bind',
     'init',
     'for',
@@ -178,7 +210,6 @@ let directiveOrder = [
     'if',
     DEFAULT,
     'teleport',
-    'element',
 ]
 
 function byPriority(a, b) {

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

@@ -1,14 +1,23 @@
-import { attributesOnly, directive, directives, into, mapAttributes, prefix, startingWith } from '../directives'
+import { directive, into, mapAttributes, prefix, startingWith } from '../directives'
 import { evaluateLater } from '../evaluator'
 import { mutateDom } from '../mutation'
 import bind from '../utils/bind'
-import { injectBindingProviders } from '../binds'
+import { applyBindingsObject, injectBindingProviders } from '../binds'
 
 mapAttributes(startingWith(':', into(prefix('bind:'))))
 
 directive('bind', (el, { value, modifiers, expression, original }, { effect }) => {
     if (! value) {
-        return applyBindingsObject(el, expression, original, effect)
+        let bindingProviders = {}
+        injectBindingProviders(bindingProviders)
+
+        let getBindings = evaluateLater(el, expression)
+
+        getBindings(bindings => {
+            applyBindingsObject(el, bindings, original)
+        }, { scope: bindingProviders } )
+
+        return
     }
 
     if (value === 'key') return storeKeyForXFor(el, expression)
@@ -17,46 +26,14 @@ directive('bind', (el, { value, modifiers, expression, original }, { effect }) =
 
     effect(() => evaluate(result => {
         // If nested object key is undefined, set the default value to empty string.
-        if (result === undefined && expression.match(/\./)) result = ''
+        if (result === undefined && typeof expression === 'string' && expression.match(/\./)) {
+            result = ''
+        }
 
         mutateDom(() => bind(el, value, result, modifiers))
     }))
 })
 
-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

+ 12 - 0
packages/alpinejs/src/directives/x-model.js

@@ -1,6 +1,7 @@
 import { evaluateLater } from '../evaluator'
 import { directive } from '../directives'
 import { mutateDom } from '../mutation'
+import { nextTick } from '../nextTick'
 import bind from '../utils/bind'
 import on from '../utils/on'
 
@@ -34,6 +35,17 @@ directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
 
     cleanup(() => el._x_removeModelListeners['default']())
 
+    // If the input/select/textarea element is linked to a form
+    // we listen for the reset event on the parent form (the event
+    // does not trigger on the single inputs) and update
+    // on nextTick so the page doesn't end up out of sync
+    if (el.form) {
+        let removeResetListener = on(el.form, 'reset', [], (e) => {
+            nextTick(() => el._x_model && el._x_model.set(el.value))
+        })
+        cleanup(() => removeResetListener())
+    }
+
     // Allow programmatic overiding of x-model.
     let evaluateSetModel = evaluateLater(el, `${expression} = __placeholder`)
     el._x_model = {

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

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

+ 7 - 1
packages/collapse/package.json

@@ -1,7 +1,13 @@
 {
     "name": "@alpinejs/collapse",
-    "version": "3.10.2",
+    "version": "3.10.5",
     "description": "Collapse and expand elements with robust animations",
+    "homepage": "https://alpinejs.dev/plugins/collapse",
+    "repository": {
+        "type": "git",
+        "url": "https://github.com/alpinejs/alpine.git",
+        "directory": "packages/collapse"
+    },
     "author": "Caleb Porzio",
     "license": "MIT",
     "main": "dist/module.cjs.js",

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

@@ -19,7 +19,7 @@ export default function (Alpine) {
         // We use the hidden attribute for the benefit of Tailwind
         // users as the .space utility will ignore [hidden] elements.
         // We also use display:none as the hidden attribute has very
-        // low CSS specificity and could be accidentally overriden
+        // low CSS specificity and could be accidentally overridden
         // by a user.
         if (! el._x_isShown && fullyHide) el.hidden = true
         if (! el._x_isShown) el.style.overflow = 'hidden'
@@ -56,7 +56,7 @@ export default function (Alpine) {
                     start: { height: current+'px' },
                     end: { height: full+'px' },
                 }, () => el._x_isShown = true, () => {
-                    if (el.style.height == `${full}px`) {
+                    if (el.getBoundingClientRect().height == full) {
                         el.style.overflow = null
                     }
                 })

+ 1 - 1
packages/docs/package.json

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

+ 24 - 2
packages/docs/src/en/directives/for.md

@@ -27,8 +27,8 @@ Alpine's `x-for` directive allows you to create DOM elements by iterating throug
 
 There are two rules worth noting about `x-for`:
 
-* `x-for` MUST be declared on a `<template>` element
-* That `<template>` element MUST have only one root element
+>`x-for` MUST be declared on a `<template>` element
+> That `<template>` element MUST contain only one root element
 
 <a name="keys"></a>
 ## Keys
@@ -85,3 +85,25 @@ If you need to simply loop `n` number of times, rather than iterate through an a
 ```
 
 `i` in this case can be named anything you like.
+
+<a name="contents-of-a-template"></a>
+## Contents of a `<template>`
+
+As mentioned above, an `<template>` tag must contain only one root element.
+
+For example, the following code will not work:
+
+```alpine
+<template x-for="color in colors">
+    <span>The next color is </span><span x-text="color">
+</template>
+```
+
+but this code will work:
+```alpine
+<template x-for="color in colors">
+    <p>
+        <span>The next color is </span><span x-text="color">
+    </p>
+</template>
+```

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

@@ -89,7 +89,7 @@ For easy reference, here is a list of common keys you may want to listen for.
 | `.caps-lock`                | Caps Lock                   |
 | `.equal`                    | Equal, `=`                  |
 | `.period`                   | Period, `.`                 |
-| `.slash`                    | Foward Slash, `/`           |
+| `.slash`                    | Forward Slash, `/`           |
 
 <a name="custom-events"></a>
 ## Custom events

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

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

+ 22 - 6
packages/docs/src/en/plugins/mask.md

@@ -12,6 +12,7 @@ Alpine's Mask plugin allows you to automatically format a text input field as a
 This is useful for many different types of inputs: phone numbers, credit cards, dollar amounts, account numbers, dates, etc.
 
 <a name="installation"></a>
+
 ## Installation
 
 <div x-data="{ expanded: false }">
@@ -60,6 +61,7 @@ Alpine.plugin(mask)
  </div>
 
 <a name="x-mask"></a>
+
 ## x-mask
 
 The primary API for using this plugin is the `x-mask` directive.
@@ -80,13 +82,14 @@ Notice how the text you type into the input field must adhere to the format prov
 
 The following wildcard characters are supported in masks:
 
-| Wildcard                   | Description                 |
-| -------------------------- | --------------------------- |
-| `*` | Any character |
-| `a` | Only alpha characters (a-z, A-Z) |
-| `9` | Only numeric characters (0-9) |
+| Wildcard | Description                      |
+| -------- | -------------------------------- |
+| `*`      | Any character                    |
+| `a`      | Only alpha characters (a-z, A-Z) |
+| `9`      | Only numeric characters (0-9)    |
 
 <a name="mask-functions"></a>
+
 ## Dynamic Masks
 
 Sometimes simple mask literals (i.e. `(999) 999-9999`) are not sufficient. In these cases, `x-mask:dynamic` allows you to dynamically generate masks on the fly based on user input.
@@ -113,7 +116,7 @@ Try it for yourself by typing a number that starts with "34" and one that doesn'
 </div>
 <!-- END_VERBATIM -->
 
-`x-mask:dynamic` also accepts a function as a result of the expression and will automatically pass it the `$input` as the the first paramter. For example:
+`x-mask:dynamic` also accepts a function as a result of the expression and will automatically pass it the `$input` as the the first parameter. For example:
 
 ```alpine
 <input x-mask:dynamic="creditCardMask">
@@ -128,6 +131,7 @@ function creditCardMask(input) {
 ```
 
 <a name="money-inputs"></a>
+
 ## Money Inputs
 
 Because writing your own dynamic mask expression for money inputs is fairly complex, Alpine offers a prebuilt one and makes it available as `$money()`.
@@ -155,3 +159,15 @@ If you wish to swap the periods for commas and vice versa (as is required in cer
     <input type="text" x-mask:dynamic="$money($input, ',')"  placeholder="0,00">
 </div>
 <!-- END_VERBATIM -->
+
+You may also choose to override the thousands separator by supplying a third optional argument:
+
+```alpine
+<input x-mask:dynamic="$money($input, '.', ' ')">
+```
+
+<!-- START_VERBATIM -->
+<div class="demo" x-data>
+    <input type="text" x-mask:dynamic="$money($input, '.', ' ')"  placeholder="3 000.00">
+</div>
+<!-- END_VERBATIM -->

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

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

+ 7 - 1
packages/focus/package.json

@@ -1,7 +1,13 @@
 {
     "name": "@alpinejs/focus",
-    "version": "3.10.2",
+    "version": "3.10.5",
     "description": "Manage focus within a page",
+    "homepage": "https://alpinejs.dev/plugins/focus",
+    "repository": {
+        "type": "git",
+        "url": "https://github.com/alpinejs/alpine.git",
+        "directory": "packages/focus"
+    },
     "author": "Caleb Porzio",
     "license": "MIT",
     "main": "dist/module.cjs.js",

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

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

+ 6 - 0
packages/history/package.json

@@ -2,6 +2,12 @@
     "name": "@alpinejs/history",
     "version": "3.0.0-alpha.0",
     "description": "Sync Alpine data with the browser's query string",
+    "homepage": "https://alpinejs.dev/",
+    "repository": {
+        "type": "git",
+        "url": "https://github.com/alpinejs/alpine.git",
+        "directory": "packages/history"
+    },
     "author": "Caleb Porzio",
     "license": "MIT",
     "main": "dist/module.cjs.js",

+ 7 - 1
packages/intersect/package.json

@@ -1,7 +1,13 @@
 {
     "name": "@alpinejs/intersect",
-    "version": "3.10.2",
+    "version": "3.10.5",
     "description": "Trigger JavaScript when an element enters the viewport",
+    "homepage": "https://alpinejs.dev/plugins/intersect",
+    "repository": {
+        "type": "git",
+        "url": "https://github.com/alpinejs/alpine.git",
+        "directory": "packages/intersect"
+    },
     "author": "Caleb Porzio",
     "license": "MIT",
     "main": "dist/module.cjs.js",

+ 7 - 1
packages/mask/package.json

@@ -1,7 +1,13 @@
 {
     "name": "@alpinejs/mask",
-    "version": "3.10.2",
+    "version": "3.10.5",
     "description": "An Alpine plugin for input masking",
+    "homepage": "https://alpinejs.dev/plugins/mask",
+    "repository": {
+        "type": "git",
+        "url": "https://github.com/alpinejs/alpine.git",
+        "directory": "packages/mask"
+    },
     "author": "Caleb Porzio",
     "license": "MIT",
     "main": "dist/module.cjs.js",

+ 8 - 9
packages/mask/src/index.js

@@ -31,7 +31,7 @@ export default function (Alpine) {
                 // Run on initialize which serves a dual purpose:
                 // - Initializing the mask on the input if it has an initial value.
                 // - Running the template function to set up reactivity, so that
-                //   when a dependancy inside it changes, the input re-masks.
+                //   when a dependency inside it changes, the input re-masks.
                 processInputValue(el)
             })
         } else {
@@ -163,9 +163,8 @@ export function buildUp(template, input) {
     return output
 }
 
-function formatMoney(input, delimeter = '.', thousands) {
-    thousands = (delimeter === ',' && thousands === undefined)
-        ? '.' : ','
+export function formatMoney(input, delimiter = '.', thousands) {
+    thousands = thousands ?? delimiter === "," ? "." : ","
 
     let addThousands = (input, thousands) => {
         let output = ''
@@ -186,17 +185,17 @@ function formatMoney(input, delimeter = '.', thousands) {
         return output
     }
 
-    let strippedInput = input.replaceAll(new RegExp(`[^0-9\\${delimeter}]`, 'g'), '')
-    let template = Array.from({ length: strippedInput.split(delimeter)[0].length }).fill('9').join('')
+    let strippedInput = input.replaceAll(new RegExp(`[^0-9\\${delimiter}]`, 'g'), '')
+    let template = Array.from({ length: strippedInput.split(delimiter)[0].length }).fill('9').join('')
 
     template = addThousands(template, thousands)
 
-    if (input.includes(delimeter)) template += `${delimeter}99`
+    if (input.includes(delimiter)) template += `${delimiter}99`
 
     queueMicrotask(() => {
-        if (this.el.value.endsWith(delimeter)) return
+        if (this.el.value.endsWith(delimiter)) return
 
-        if (this.el.value[this.el.selectionStart - 1] === delimeter) {
+        if (this.el.value[this.el.selectionStart - 1] === delimiter) {
             this.el.setSelectionRange(this.el.selectionStart - 1, this.el.selectionStart - 1)
         }
     })

+ 7 - 1
packages/morph/package.json

@@ -1,7 +1,13 @@
 {
     "name": "@alpinejs/morph",
-    "version": "3.10.2",
+    "version": "3.10.5",
     "description": "Diff and patch a block of HTML on a page with an HTML template",
+    "homepage": "https://alpinejs.dev/plugins/morph",
+    "repository": {
+        "type": "git",
+        "url": "https://github.com/alpinejs/alpine.git",
+        "directory": "packages/morph"
+    },
     "author": "Caleb Porzio",
     "license": "MIT",
     "main": "dist/module.cjs.js",

+ 7 - 1
packages/persist/package.json

@@ -1,7 +1,13 @@
 {
     "name": "@alpinejs/persist",
-    "version": "3.10.2",
+    "version": "3.10.5",
     "description": "Persist Alpine data across page loads",
+    "homepage": "https://alpinejs.dev/plugins/persist",
+    "repository": {
+        "type": "git",
+        "url": "https://github.com/alpinejs/alpine.git",
+        "directory": "packages/persist"
+    },
     "author": "Caleb Porzio",
     "license": "MIT",
     "main": "dist/module.cjs.js",

+ 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 - 11
packages/portal/package.json

@@ -1,11 +0,0 @@
-{
-    "name": "@alpinejs/portal",
-    "version": "3.6.1-beta.0",
-    "description": "Send Alpine templates to other parts of the DOM",
-    "author": "Caleb Porzio",
-    "license": "MIT",
-    "main": "dist/module.cjs.js",
-    "module": "dist/module.esm.js",
-    "unpkg": "dist/cdn.min.js",
-    "dependencies": {}
-}

+ 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

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 18 - 0
packages/ui/demo/index.html


+ 105 - 0
packages/ui/demo/listbox/data-driven.html

@@ -0,0 +1,105 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <meta charSet="utf-8" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
+    <link rel="icon" type="image/png" sizes="32x32" href="https://headlessui.dev/favicon-32x32.png" />
+    <link rel="icon" type="image/png" sizes="16x16" href="https://headlessui.dev/favicon-16x16.png" />
+
+    <script src="/packages/intersect/dist/cdn.js" defer></script>
+    <script src="/packages/morph/dist/cdn.js" defer></script>
+    <script src="/packages/history/dist/cdn.js"></script>
+    <script src="/packages/persist/dist/cdn.js"></script>
+    <script src="/packages/focus/dist/cdn.js"></script>
+    <script src="/packages/mask/dist/cdn.js"></script>
+    <script src="/packages/ui/dist/cdn.js" defer></script>
+    <script src="/packages/alpinejs/dist/cdn.js" defer></script>
+    <script src="//cdn.tailwindcss.com"></script>
+
+    <title>Listbox</title>
+</head>
+
+<body>
+    <div class="flex flex-col h-screen overflow-hidden font-sans antialiased text-gray-900 bg-gray-700">
+        <div
+            x-data="{ selected: undefined, people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb' },
+                { id: 4, name: 'Tom Cook' },
+                { id: 5, name: 'Tanya Fox', disabled: true },
+                { id: 6, name: 'Hellen Schmidt' },
+                { id: 7, name: 'Caroline Schultz' },
+                { id: 8, name: 'Mason Heaney' },
+                { id: 9, name: 'Claudie Smitham' },
+                { id: 10, name: 'Emil Schaefer' },
+                ]}"
+            class="flex justify-center w-screen h-full p-12 bg-gray-50"
+        >
+            <div class="w-full max-w-xs mx-auto">
+                <div class="flex justify-between mb-8">
+                    <button class="underline" @click="selected = people[1]">Change value</button>
+
+                    <button class="underline" @click="
+                        people.sort((a, b) => a.name > b.name ? 1 : -1)
+                    ">Reorder</button>
+
+                    <button class="underline" @click="
+                        people = people.filter(i => i.name !== 'Arlene Mccoy')
+                    ">Destroy item</button>
+                </div>
+
+                <div x-listbox name="something" x-model="selected" class="space-y-1">
+                    <label x-listbox:label class="block text-sm font-medium leading-5 text-gray-700 mb-1">
+                        Assigned to
+                    </label>
+
+                    <div class="relative">
+                        <span class="inline-block w-full rounded-md shadow-sm">
+                            <button x-listbox:button class="relative w-full py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
+                                <span class="block truncate" x-text="selected ? selected.name : 'Select Person'"></span>
+                                <span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
+                                    <svg class="w-5 h-5 text-gray-400" viewBox="0 0 20 20" fill="none" stroke="currentColor">
+                                        <path d="M7 7l3-3 3 3m0 6l-3 3-3-3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
+                                    </svg>
+                                </span>
+                            </button>
+                        </span>
+
+                        <div class="absolute w-full mt-1 bg-white rounded-md shadow-lg">
+                            <ul x-listbox:options class="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5">
+                                <template x-for="person in people" :key="person.id">
+                                    <li
+                                        x-listbox:option :value="person"
+                                        class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
+                                        :disabled="person.disabled"
+                                        :class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900', person.disabled && 'bg-gray-50 text-gray-300'].filter(Boolean)"
+                                    >
+                                        <span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'" x-text="person.name"></span>
+
+                                        <span
+                                            x-show="$listboxOption.isSelected"
+                                            class="absolute inset-y-0 right-0 flex items-center pr-4"
+                                            :class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
+                                        >
+                                            <svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
+                                                <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
+                                            </svg>
+                                        </span>
+                                    </li>
+                                </template>
+                            </ul>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</body>
+</html>
+
+
+

+ 233 - 0
packages/ui/demo/listbox/index.html

@@ -0,0 +1,233 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <meta charSet="utf-8" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
+    <link rel="icon" type="image/png" sizes="32x32" href="https://headlessui.dev/favicon-32x32.png" />
+    <link rel="icon" type="image/png" sizes="16x16" href="https://headlessui.dev/favicon-16x16.png" />
+
+    <script src="/packages/intersect/dist/cdn.js" defer></script>
+    <script src="/packages/morph/dist/cdn.js" defer></script>
+    <script src="/packages/history/dist/cdn.js"></script>
+    <script src="/packages/persist/dist/cdn.js"></script>
+    <script src="/packages/focus/dist/cdn.js"></script>
+    <script src="/packages/mask/dist/cdn.js"></script>
+    <script src="/packages/ui/dist/cdn.js" defer></script>
+    <script src="/packages/alpinejs/dist/cdn.js" defer></script>
+    <script src="//cdn.tailwindcss.com"></script>
+
+    <title>Listbox</title>
+</head>
+
+<body>
+    <div class="flex flex-col h-screen overflow-hidden font-sans antialiased text-gray-900 bg-gray-700">
+        <div x-data="{ selected: 'Wade Cooper' }" class="flex justify-center w-screen h-full p-12 bg-gray-50">
+            <div class="w-full max-w-xs mx-auto">
+                <div class="flex justify-between mb-8">
+                    <button class="underline" @click="selected = Arlene Mccoy">Change value</button>
+                </div>
+
+                <div x-listbox name="something" x-model="selected" class="space-y-1">
+                    <label x-listbox:label class="block text-sm font-medium leading-5 text-gray-700 mb-1">
+                        Assigned to
+                    </label>
+
+                    <div class="relative">
+                        <span class="inline-block w-full rounded-md shadow-sm">
+                            <button x-listbox:button class="relative w-full py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
+                                <span class="block truncate" x-text="selected"></span>
+                                <span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
+                                    <svg class="w-5 h-5 text-gray-400" viewBox="0 0 20 20" fill="none" stroke="currentColor">
+                                        <path d="M7 7l3-3 3 3m0 6l-3 3-3-3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
+                                    </svg>
+                                </span>
+                            </button>
+                        </span>
+
+                        <div class="absolute w-full mt-1 bg-white rounded-md shadow-lg">
+                            <ul x-listbox:options class="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5">
+                                <li
+                                    x-listbox:option value="Wade Cooper"
+                                    class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
+                                    :class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900'].filter(Boolean)"
+                                >
+                                    <span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Wade Cooper</span>
+
+                                    <span
+                                        x-show="$listboxOption.isSelected"
+                                        class="absolute inset-y-0 right-0 flex items-center pr-4"
+                                        :class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
+                                    >
+                                        <svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
+                                            <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
+                                        </svg>
+                                    </span>
+                                </li>
+                                <li
+                                    x-listbox:option value="Arlene Mccoy"
+                                    class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
+                                    :class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900'].filter(Boolean)"
+                                >
+                                    <span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Arlene Mccoy</span>
+
+                                    <span
+                                        x-show="$listboxOption.isSelected"
+                                        class="absolute inset-y-0 right-0 flex items-center pr-4"
+                                        :class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
+                                    >
+                                        <svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
+                                            <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
+                                        </svg>
+                                    </span>
+                                </li>
+                                <li
+                                    x-listbox:option value="Devon Webb"
+                                    class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
+                                    :class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900'].filter(Boolean)"
+                                >
+                                    <span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Devon Webb</span>
+
+                                    <span
+                                        x-show="$listboxOption.isSelected"
+                                        class="absolute inset-y-0 right-0 flex items-center pr-4"
+                                        :class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
+                                    >
+                                        <svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
+                                            <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
+                                        </svg>
+                                    </span>
+                                </li>
+                                <li
+                                    x-listbox:option value="Tom Cook"
+                                    class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
+                                    :class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900'].filter(Boolean)"
+                                >
+                                    <span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Tom Cook</span>
+
+                                    <span
+                                        x-show="$listboxOption.isSelected"
+                                        class="absolute inset-y-0 right-0 flex items-center pr-4"
+                                        :class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
+                                    >
+                                        <svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
+                                            <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
+                                        </svg>
+                                    </span>
+                                </li>
+                                <li
+                                    x-listbox:option value="Tanya Fox"
+                                    class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
+                                    :class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900'].filter(Boolean)"
+                                >
+                                    <span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Tanya Fox</span>
+
+                                    <span
+                                        x-show="$listboxOption.isSelected"
+                                        class="absolute inset-y-0 right-0 flex items-center pr-4"
+                                        :class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
+                                    >
+                                        <svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
+                                            <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
+                                        </svg>
+                                    </span>
+                                </li>
+                                <li
+                                    x-listbox:option value="Hellen Schmidt"
+                                    class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
+                                    :class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900'].filter(Boolean)"
+                                >
+                                    <span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Hellen Schmidt</span>
+
+                                    <span
+                                        x-show="$listboxOption.isSelected"
+                                        class="absolute inset-y-0 right-0 flex items-center pr-4"
+                                        :class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
+                                    >
+                                        <svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
+                                            <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
+                                        </svg>
+                                    </span>
+                                </li>
+                                <li
+                                    x-listbox:option value="Caroline Schultz"
+                                    class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
+                                    :class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900'].filter(Boolean)"
+                                >
+                                    <span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Caroline Schultz</span>
+
+                                    <span
+                                        x-show="$listboxOption.isSelected"
+                                        class="absolute inset-y-0 right-0 flex items-center pr-4"
+                                        :class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
+                                    >
+                                        <svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
+                                            <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
+                                        </svg>
+                                    </span>
+                                </li>
+                                <li
+                                    x-listbox:option value="Mason Heaney"
+                                    class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
+                                    :class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900'].filter(Boolean)"
+                                >
+                                    <span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Mason Heaney</span>
+
+                                    <span
+                                        x-show="$listboxOption.isSelected"
+                                        class="absolute inset-y-0 right-0 flex items-center pr-4"
+                                        :class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
+                                    >
+                                        <svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
+                                            <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
+                                        </svg>
+                                    </span>
+                                </li>
+                                <li
+                                    x-listbox:option value="Claudie Smitham"
+                                    class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
+                                    :class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900'].filter(Boolean)"
+                                >
+                                    <span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Claudie Smitham</span>
+
+                                    <span
+                                        x-show="$listboxOption.isSelected"
+                                        class="absolute inset-y-0 right-0 flex items-center pr-4"
+                                        :class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
+                                    >
+                                        <svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
+                                            <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
+                                        </svg>
+                                    </span>
+                                </li>
+                                <li
+                                    x-listbox:option value="Emil Schaefer"
+                                    class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
+                                    :class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900'].filter(Boolean)"
+                                >
+                                    <span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'">Emil Schaefer</span>
+
+                                    <span
+                                        x-show="$listboxOption.isSelected"
+                                        class="absolute inset-y-0 right-0 flex items-center pr-4"
+                                        :class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
+                                    >
+                                        <svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
+                                            <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
+                                        </svg>
+                                    </span>
+                                </li>
+                            </ul>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</body>
+</html>
+
+
+

+ 105 - 0
packages/ui/demo/listbox/multiple.html

@@ -0,0 +1,105 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <meta charSet="utf-8" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
+    <link rel="icon" type="image/png" sizes="32x32" href="https://headlessui.dev/favicon-32x32.png" />
+    <link rel="icon" type="image/png" sizes="16x16" href="https://headlessui.dev/favicon-16x16.png" />
+
+    <script src="/packages/intersect/dist/cdn.js" defer></script>
+    <script src="/packages/morph/dist/cdn.js" defer></script>
+    <script src="/packages/history/dist/cdn.js"></script>
+    <script src="/packages/persist/dist/cdn.js"></script>
+    <script src="/packages/focus/dist/cdn.js"></script>
+    <script src="/packages/mask/dist/cdn.js"></script>
+    <script src="/packages/ui/dist/cdn.js" defer></script>
+    <script src="/packages/alpinejs/dist/cdn.js" defer></script>
+    <script src="//cdn.tailwindcss.com"></script>
+
+    <title>Listbox</title>
+</head>
+
+<body>
+    <div class="flex flex-col h-screen overflow-hidden font-sans antialiased text-gray-900 bg-gray-700">
+        <div
+            x-data="{ selected: [], people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb' },
+                { id: 4, name: 'Tom Cook' },
+                { id: 5, name: 'Tanya Fox', disabled: true },
+                { id: 6, name: 'Hellen Schmidt' },
+                { id: 7, name: 'Caroline Schultz' },
+                { id: 8, name: 'Mason Heaney' },
+                { id: 9, name: 'Claudie Smitham' },
+                { id: 10, name: 'Emil Schaefer' },
+            ]}"
+            class="flex justify-center w-screen h-full p-12 bg-gray-50"
+        >
+            <div class="w-full max-w-xs mx-auto">
+                <div class="flex justify-between mb-8">
+                    <button class="underline" @click="selected.push(people[1])">Change value</button>
+
+                    <button class="underline" @click="
+                        people.sort((a, b) => a.name > b.name ? 1 : -1)
+                    ">Reorder</button>
+
+                    <button class="underline" @click="
+                        people = people.filter(i => i.name !== 'Arlene Mccoy')
+                    ">Destroy item</button>
+                </div>
+
+                <div x-listbox name="people" x-model="selected" multiple class="space-y-1">
+                    <label x-listbox:label class="block text-sm font-medium leading-5 text-gray-700 mb-1">
+                        Assigned to
+                    </label>
+
+                    <div class="relative">
+                        <span class="inline-block w-full rounded-md shadow-sm">
+                            <button x-listbox:button class="relative w-full py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
+                                <span class="block truncate" x-text="selected.length > 0 ? selected.map(i => i.name).join(', ') : 'Select Person'"></span>
+                                <span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
+                                    <svg class="w-5 h-5 text-gray-400" viewBox="0 0 20 20" fill="none" stroke="currentColor">
+                                        <path d="M7 7l3-3 3 3m0 6l-3 3-3-3" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
+                                    </svg>
+                                </span>
+                            </button>
+                        </span>
+
+                        <div class="absolute w-full mt-1 bg-white rounded-md shadow-lg">
+                            <ul x-listbox:options class="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5">
+                                <template x-for="person in people" :key="person.id">
+                                    <li
+                                        x-listbox:option :value="person"
+                                        class="relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none"
+                                        :disabled="person.disabled"
+                                        :class="[$listboxOption.isActive ? 'text-white bg-indigo-600' : 'text-gray-900', person.disabled && 'bg-gray-50 text-gray-300'].filter(Boolean)"
+                                        >
+                                        <span class="block truncate" :class="$listboxOption.isSelected ? 'font-semibold' : 'font-normal'" x-text="person.name"></span>
+
+                                        <span
+                                            x-show="$listboxOption.isSelected"
+                                            class="absolute inset-y-0 right-0 flex items-center pr-4"
+                                            :class="$listboxOption.isActive ? 'text-white' : 'text-indigo-600'"
+                                        >
+                                            <svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
+                                                <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
+                                            </svg>
+                                        </span>
+                                    </li>
+                                </template>
+                            </ul>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</body>
+</html>
+
+
+

+ 17 - 0
packages/ui/package.json

@@ -0,0 +1,17 @@
+{
+    "name": "@alpinejs/ui",
+    "version": "3.10.5-beta.7",
+    "description": "Headless UI components for Alpine",
+    "homepage": "https://alpinejs.dev/components#headless",
+    "repository": {
+        "type": "git",
+        "url": "https://github.com/alpinejs/alpine.git",
+        "directory": "packages/ui"
+    },
+    "author": "Caleb Porzio",
+    "license": "MIT",
+    "main": "dist/module.cjs.js",
+    "module": "dist/module.esm.js",
+    "unpkg": "dist/cdn.min.js",
+    "dependencies": {}
+}

+ 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> */

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

@@ -0,0 +1,96 @@
+
+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 {
+            // Kept here for legacy. Remove after out of beta.
+            get open() {
+                return $data.__isOpen
+            },
+            get isOpen() {
+                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') },
+    })
+}
+

+ 80 - 0
packages/ui/src/disclosure.js

@@ -0,0 +1,80 @@
+
+export default function (Alpine) {
+    Alpine.directive('disclosure', (el, directive) => {
+        if      (! directive.value)            handleRoot(el, Alpine)
+        else if (directive.value === 'panel')  handlePanel(el, Alpine)
+        else if (directive.value === 'button') handleButton(el, Alpine)
+    })
+
+    Alpine.magic('disclosure', el => {
+        let $data = Alpine.$data(el)
+
+        return {
+            get isOpen() {
+                return $data.__isOpen
+            },
+            close() {
+                $data.__close()
+            }
+        }
+    })
+}
+
+function handleRoot(el, Alpine) {
+    Alpine.bind(el, {
+        'x-modelable': '__isOpen',
+        'x-data'() {
+            return {
+                init() {
+                    queueMicrotask(() => {
+                         let defaultIsOpen = Boolean(Alpine.bound(this.$el, 'default-open', false))
+
+                         if (defaultIsOpen) this.__isOpen = defaultIsOpen
+                    })
+                },
+                __isOpen: false,
+                __close() {
+                    this.__isOpen = false
+                },
+                __toggle() {
+                    this.__isOpen = ! this.__isOpen
+                },
+            }
+        },
+        'x-id'() { return ['alpine-disclosure-panel'] },
+    })
+}
+
+function handleButton(el, Alpine) {
+    Alpine.bind(el, {
+        'x-init'() {
+            if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button'
+        },
+        '@click'() {
+            this.$data.__isOpen = ! this.$data.__isOpen
+        },
+        ':aria-expanded'() {
+            return this.$data.__isOpen
+        },
+        ':aria-controls'() {
+            return this.$data.$id('alpine-disclosure-panel')
+        },
+        '@keydown.space.prevent.stop'() { this.$data.__toggle() },
+        '@keydown.enter.prevent.stop'() { this.$data.__toggle() },
+        // Required for firefox, event.preventDefault() in handleKeyDown for
+        // the Space key doesn't cancel the handleKeyUp, which in turn
+        // triggers a *click*.
+        '@keyup.space.prevent'() {},
+    })
+}
+
+function handlePanel(el, Alpine) {
+    Alpine.bind(el, {
+        'x-show'() {
+            return this.$data.__isOpen
+        },
+        ':id'() {
+            return this.$data.$id('alpine-disclosure-panel')
+        },
+    })
+}

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

@@ -0,0 +1,19 @@
+import dialog from './dialog'
+import disclosure from './disclosure'
+import listbox from './listbox'
+import popover from './popover'
+import menu from './menu'
+import notSwitch from './switch'
+import radio from './radio'
+import tabs from './tabs'
+
+export default function (Alpine) {
+    dialog(Alpine)
+    disclosure(Alpine)
+    listbox(Alpine)
+    menu(Alpine)
+    notSwitch(Alpine)
+    popover(Alpine)
+    radio(Alpine)
+    tabs(Alpine)
+}

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

@@ -0,0 +1,460 @@
+import Alpine from "../../alpinejs/src/alpine"
+
+export function generateContext(multiple, orientation) {
+    return {
+        /**
+         * Main state...
+         */
+        searchableText: {},
+        disabledKeys: [],
+        activeKey: null,
+        selectedKeys: [],
+        orderedKeys: [],
+        elsByKey: {},
+        values: {},
+
+        /**
+         *  Initialization...
+         */
+        initItem(el, value, disabled) {
+            let key = (Math.random() + 1).toString(36).substring(7)
+
+            // Register value by key...
+            this.values[key] = value
+
+            // Associate key with element...
+            this.elsByKey[key] = el
+
+            // Register key for ordering...
+            this.orderedKeys.push(key)
+
+            // Register key for searching...
+            this.searchableText[key] = el.textContent.trim().toLowerCase()
+
+            // Store whether disabled or not...
+            disabled && this.disabledKeys.push(key)
+
+            return key
+        },
+
+        destroyItem(el) {
+            let key = keyByValue(this.elsByKey, el)
+
+            delete this.values[key]
+            delete this.elsByKey[key]
+            delete this.orderedKeys[this.orderedKeys.indexOf(key)]
+            delete this.searchableText[key]
+            delete this.disabledKeys[key]
+
+            this.reorderKeys()
+        },
+
+        /**
+         * Handle elements...
+         */
+         reorderKeys() {
+            // Filter out elements removed from the dom...
+            this.orderedKeys.forEach((key) => {
+                let el = this.elsByKey[key]
+
+                if (el.isConnected) return
+
+                this.destroyItem(el)
+            })
+
+            this.orderedKeys = this.orderedKeys.slice().sort((a, z) => {
+                if (a === null || z === null) return 0
+
+                let aEl = this.elsByKey[a]
+                let zEl = this.elsByKey[z]
+
+                let position = aEl.compareDocumentPosition(zEl)
+
+                if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
+                if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
+                return 0
+            })
+        },
+
+        activeEl() {
+            if (! this.activeKey) return
+
+            return this.elsByKey[this.activeKey]
+        },
+
+        isActiveEl(el) {
+            let key = keyByValue(this.elsByKey, el)
+
+            if (! key) return
+
+            return this.activeKey === key
+        },
+
+        activateEl(el) {
+            let key = keyByValue(this.elsByKey, el)
+
+            if (! key) return
+
+            this.activateKey(key)
+        },
+
+        selectEl(el) {
+            let key = keyByValue(this.elsByKey, el)
+
+            if (! key) return
+
+            this.selectKey(key)
+        },
+
+        isSelectedEl(el) {
+            let key = keyByValue(this.elsByKey, el)
+
+            if (! key) return
+
+            return this.isSelected(key)
+        },
+
+        isDisabledEl(el) {
+            let key = keyByValue(this.elsByKey, el)
+
+            if (! key) return
+
+            return this.isDisabled(key)
+        },
+
+        get isScrollingTo() { return this.scrollingCount > 0 },
+
+        scrollingCount: 0,
+
+        activateAndScrollToKey(key) {
+            // This addresses the following problem:
+            // If deactivate is hooked up to mouseleave,
+            // scrolling to an element will trigger deactivation.
+            // This "isScrollingTo" is exposed to prevent that.
+            this.scrollingCount++
+
+            this.activateKey(key)
+
+            let targetEl = this.elsByKey[key]
+
+            targetEl.scrollIntoView({ block: 'nearest' })
+
+            setTimeout(() => {
+                this.scrollingCount--
+            // Unfortunately, browser experimentation has shown me
+            // that 25ms is the sweet spot when holding down an
+            // arrow key to scroll the list of items...
+            }, 25)
+        },
+
+        /**
+         * Handle values...
+         */
+        selectedValueOrValues() {
+            if (multiple) {
+                return this.selectedValues()
+            } else {
+                return this.selectedValue()
+            }
+        },
+
+        selectedValues() {
+            return this.selectedKeys.map(i => this.values[i])
+        },
+
+        selectedValue() {
+            return this.selectedKeys[0] ? this.values[this.selectedKeys[0]] : null
+        },
+
+        selectValue(value, by) {
+            if (!value) value = (multiple ? [] : null)
+            if (! by) by = (a, b) => a === b
+
+            if (typeof by === 'string') {
+                let property = by
+                by = (a, b) => a[property] === b[property]
+            }
+
+            if (multiple) {
+                // debugger
+                let keys = []
+
+                value.forEach(i => {
+                    for (let key in this.values) {
+                        if (by(this.values[key], i)) {
+                            if (! keys.includes(key)) {
+                                keys.push(key)
+                            }
+                        }
+                    }
+                })
+
+                this.selectExclusive(keys)
+            } else {
+                for (let key in this.values) {
+                    if (value && by(this.values[key], value)) {
+                        this.selectKey(key)
+                    }
+                }
+            }
+        },
+
+        /**
+         * Handle disabled keys...
+         */
+        isDisabled(key) { return this.disabledKeys.includes(key) },
+
+        get nonDisabledOrderedKeys() {
+            return this.orderedKeys.filter(i => ! this.isDisabled(i))
+        },
+
+        /**
+         * Handle selected keys...
+         */
+        selectKey(key) {
+            if (this.isDisabled(key)) return
+
+            if (multiple) {
+                this.toggleSelected(key)
+            } else {
+                this.selectOnly(key)
+            }
+        },
+
+        toggleSelected(key) {
+            if (this.selectedKeys.includes(key)) {
+                this.selectedKeys.splice(this.selectedKeys.indexOf(key), 1)
+            } else {
+                this.selectedKeys.push(key)
+            }
+        },
+
+        selectOnly(key) {
+            this.selectedKeys = []
+            this.selectedKeys.push(key)
+        },
+
+        selectExclusive(keys) {
+            // We can't just do this.selectedKeys = keys,
+            // because we need to preserve reactivity...
+
+            let toAdd = [...keys]
+
+            for (let i = 0; i < this.selectedKeys.length; i++) {
+                if (keys.includes(this.selectedKeys[i])) {
+                    delete toAdd[toAdd.indexOf(this.selectedKeys[i])]
+                    continue;
+                }
+
+                if (! keys.includes(this.selectedKeys[i])) {
+                    delete this.selectedKeys[i]
+                }
+            }
+
+            toAdd.forEach(i => {
+                this.selectedKeys.push(i)
+            })
+        },
+
+        selectActive(key) {
+            if (! this.activeKey) return
+
+            this.selectKey(this.activeKey)
+        },
+
+        isSelected(key) { return this.selectedKeys.includes(key) },
+
+
+        firstSelectedKey() { return this.selectedKeys[0] },
+
+        /**
+         * Handle activated keys...
+         */
+        hasActive() { return !! this.activeKey },
+
+        isActiveKey(key) { return this.activeKey === key },
+
+        get active() { return this.hasActive() && this.values[this.activeKey] },
+
+        activateSelectedOrFirst() {
+            let firstSelected = this.firstSelectedKey()
+
+            if (firstSelected) {
+                return this.activateKey(firstSelected)
+            }
+
+            let firstKey = this.firstKey()
+
+            if (firstKey) {
+                this.activateKey(firstKey)
+            }
+        },
+
+        activateKey(key) {
+            if (this.isDisabled(key)) return
+
+            this.activeKey = key
+        },
+
+        deactivate() {
+            if (! this.activeKey) return
+            if (this.isScrollingTo) return
+
+            this.activeKey = null
+        },
+
+        /**
+         * Handle active key traveral...
+         */
+        nextKey() {
+            if (! this.activeKey) return
+
+            let index = this.nonDisabledOrderedKeys.findIndex(i => i === this.activeKey)
+
+            return this.nonDisabledOrderedKeys[index + 1]
+        },
+
+        prevKey() {
+            if (! this.activeKey) return
+
+            let index = this.nonDisabledOrderedKeys.findIndex(i => i === this.activeKey)
+
+            return this.nonDisabledOrderedKeys[index - 1]
+        },
+
+        firstKey() { return this.nonDisabledOrderedKeys[0] },
+
+        lastKey() { return this.nonDisabledOrderedKeys[this.nonDisabledOrderedKeys.length - 1] },
+
+        searchQuery: '',
+
+        clearSearch: Alpine.debounce(function () { this.searchQuery = '' }, 350),
+
+        searchKey(query) {
+            this.clearSearch()
+
+            this.searchQuery += query
+
+            let foundKey
+
+            for (let key in this.searchableText) {
+                let content = this.searchableText[key]
+
+                if (content.startsWith(this.searchQuery)) {
+                    foundKey = key
+                    break;
+                }
+            }
+
+            if (! this.nonDisabledOrderedKeys.includes(foundKey)) return
+
+            return foundKey
+        },
+
+        activateByKeyEvent(e) {
+            this.reorderKeys()
+
+            let hasActive = this.hasActive()
+
+            let targetKey
+
+            switch (e.key) {
+                case 'Tab':
+                case 'Backspace':
+                case 'Delete':
+                case 'Meta':
+                    break;
+
+                    break;
+                case ['ArrowDown', 'ArrowRight'][orientation === 'vertical' ? 0 : 1]:
+                    e.preventDefault(); e.stopPropagation()
+                    targetKey = hasActive ? this.nextKey() : this.firstKey()
+                    break;
+
+                case ['ArrowUp', 'ArrowLeft'][orientation === 'vertical' ? 0 : 1]:
+                    e.preventDefault(); e.stopPropagation()
+                    targetKey = hasActive ? this.prevKey() : this.lastKey()
+                    break;
+                case 'Home':
+                case 'PageUp':
+                    e.preventDefault(); e.stopPropagation()
+                    targetKey = this.firstKey()
+                    break;
+
+                case 'End':
+                case 'PageDown':
+                    e.preventDefault(); e.stopPropagation()
+                    targetKey = this.lastKey()
+                    break;
+
+                default:
+                    if (e.key.length === 1) {
+                        targetKey = this.searchKey(e.key)
+                    }
+                    break;
+            }
+
+            if (targetKey) {
+                this.activateAndScrollToKey(targetKey)
+            }
+        }
+    }
+}
+
+function keyByValue(object, value) {
+    return Object.keys(object).find(key => object[key] === value)
+}
+
+export function renderHiddenInputs(el, name, value) {
+    // Create input elements...
+    let newInputs = generateInputs(name, value)
+
+    // Mark them for later tracking...
+    newInputs.forEach(i => i._x_hiddenInput = true)
+
+    // Mark them for Alpine ignoring...
+    newInputs.forEach(i => i._x_ignore = true)
+
+    // Gather old elements for removal...
+    let children = el.children
+
+    let oldInputs = []
+
+    for (let i = 0; i < children.length; i++) {
+        let child = children[i];
+
+        if (child._x_hiddenInput) oldInputs.push(child)
+        else break
+    }
+
+    // Remove old, and insert new ones into the DOM...
+    Alpine.mutateDom(() => {
+        oldInputs.forEach(i => i.remove())
+
+        newInputs.reverse().forEach(i => el.prepend(i))
+    })
+}
+
+function generateInputs(name, value, carry = []) {
+    if (isObjectOrArray(value)) {
+        for (let key in value) {
+            carry = carry.concat(
+                generateInputs(`${name}[${key}]`, value[key])
+            )
+        }
+    } else {
+        let el = document.createElement('input')
+        el.setAttribute('type', 'hidden')
+        el.setAttribute('name', name)
+        el.setAttribute('value', '' + value)
+
+        return [el]
+    }
+
+
+    return carry
+}
+
+function isObjectOrArray(subject) {
+    return typeof subject === 'object' && subject !== null
+}

+ 244 - 0
packages/ui/src/listbox.js

@@ -0,0 +1,244 @@
+import { generateContext, renderHiddenInputs } from './list-context'
+
+export default function (Alpine) {
+    Alpine.directive('listbox', (el, directive) => {
+        if (! directive.value) handleRoot(el, Alpine)
+        else if (directive.value === 'label') handleLabel(el, Alpine)
+        else if (directive.value === 'button') handleButton(el, Alpine)
+        else if (directive.value === 'options') handleOptions(el, Alpine)
+        else if (directive.value === 'option') handleOption(el, Alpine)
+    })
+
+    Alpine.magic('listbox', (el) => {
+        let data = Alpine.$data(el)
+
+        if (! data.__ready) return {
+            isDisabled: false,
+            isOpen: false,
+            selected: null,
+            active: null,
+        }
+
+        return {
+            get isOpen() {
+                return data.__isOpen
+            },
+            get isDisabled() {
+                return data.__isDisabled
+            },
+            get selected() {
+                return data.__value
+            },
+            get active() {
+                return data.__context.active
+            },
+        }
+    })
+
+    Alpine.magic('listboxOption', (el) => {
+        let data = Alpine.$data(el)
+
+        let stub = {
+            isDisabled: false,
+            isSelected: false,
+            isActive: false,
+        }
+
+        if (! data.__ready) return stub
+
+        let optionEl = Alpine.findClosest(el, i => i.__optionKey)
+
+        if (! optionEl) return stub
+
+        let context = data.__context
+
+        return {
+            get isActive() {
+                return context.isActiveEl(optionEl)
+            },
+            get isSelected() {
+                return context.isSelectedEl(optionEl)
+            },
+            get isDisabled() {
+                return context.isDisabledEl(optionEl)
+            },
+        }
+    })
+}
+
+function handleRoot(el, Alpine) {
+    Alpine.bind(el, {
+        'x-id'() { return ['alpine-listbox-button', 'alpine-listbox-options', 'alpine-listbox-label'] },
+        'x-modelable': '__value',
+        'x-data'() {
+            return {
+                __ready: false,
+                __value: null,
+                __isOpen: false,
+                __context: undefined,
+                __isMultiple: undefined,
+                __isStatic: false,
+                __isDisabled: undefined,
+                __compareBy: null,
+                __inputName: null,
+                __orientation: 'vertical',
+                init() {
+                    this.__isMultiple = Alpine.bound(el, 'multiple', false)
+                    this.__isDisabled = Alpine.bound(el, 'disabled', false)
+                    this.__inputName = Alpine.bound(el, 'name', null)
+                    this.__compareBy = Alpine.bound(el, 'by')
+                    this.__orientation = Alpine.bound(el, 'horizontal', false) ? 'horizontal' : 'vertical'
+
+                    this.__context = generateContext(this.__isMultiple, this.__orientation)
+
+                    let defaultValue = Alpine.bound(el, 'default-value', null)
+
+                    this.__value = defaultValue
+
+                    // We have to wait for the rest of the HTML to initialize in Alpine before
+                    // we mark this component as "ready".
+                    queueMicrotask(() => {
+                        this.__ready = true
+
+                        // We have to wait again until after the "ready" processes are finished
+                        // to settle up currently selected Values (this prevents this next bit
+                        // of code from running multiple times on startup...)
+                        queueMicrotask(() => {
+                            // This "fingerprint" acts as a checksum of the last-known "value"
+                            // passed into x-model. We need to track this so that we can determine
+                            // from the reactive effect if it was the value that changed externally
+                            // or an option was selected internally...
+                            let lastValueFingerprint = false
+
+                            Alpine.effect(() => {
+                                // Accessing selected keys, so a change in it always triggers this effect...
+                                this.__context.selectedKeys
+
+                                if (lastValueFingerprint === false || lastValueFingerprint !== JSON.stringify(this.__value)) {
+                                    // Here we know that the value changed externally and we can add the selection...
+                                    this.__context.selectValue(this.__value, this.__compareBy)
+                                } else {
+                                    // Here we know that an option was selected and we can change the value...
+                                    this.__value = this.__context.selectedValueOrValues()
+                                }
+
+                                // Generate the "value" checksum for comparison next time...
+                                lastValueFingerprint = JSON.stringify(this.__value)
+
+                                // Everytime the value changes, we need to re-render the hidden inputs,
+                                // if a user passed the "name" prop...
+                                this.__inputName && renderHiddenInputs(this.$el, this.__inputName, this.__value)
+                            })
+                        })
+                    })
+                },
+                __open() {
+                    this.__isOpen = true
+
+                    this.__context.activateSelectedOrFirst()
+
+                    // Safari needs more of a "tick" for focusing after x-show for some reason.
+                    // Probably because Alpine adds an extra tick when x-showing for @click.outside
+                    let nextTick = callback => requestAnimationFrame(() => requestAnimationFrame(callback))
+
+                    nextTick(() => this.$refs.__options.focus({ preventScroll: true }))
+                },
+                __close() {
+                    this.__isOpen = false
+
+                    this.$nextTick(() => this.$refs.__button.focus({ preventScroll: true }))
+                }
+            }
+        },
+    })
+}
+
+function handleLabel(el, Alpine) {
+    Alpine.bind(el, {
+        'x-ref': '__label',
+        ':id'() { return this.$id('alpine-listbox-label') },
+        '@click'() { this.$refs.__button.focus({ preventScroll: true }) },
+    })
+}
+
+function handleButton(el, Alpine) {
+    Alpine.bind(el, {
+        'x-ref': '__button',
+        ':id'() { return this.$id('alpine-listbox-button') },
+        'aria-haspopup': 'true',
+        ':aria-labelledby'() { return this.$id('alpine-listbox-label') },
+        ':aria-expanded'() { return this.$data.__isOpen },
+        ':aria-controls'() { return this.$data.__isOpen && this.$id('alpine-listbox-options') },
+        'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button' },
+        '@click'() { this.$data.__open() },
+        '@keydown'(e) {
+            if (['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
+                e.stopPropagation()
+                e.preventDefault()
+
+                this.$data.__open()
+            }
+        },
+        '@keydown.space.stop.prevent'() { this.$data.__open() },
+        '@keydown.enter.stop.prevent'() { this.$data.__open() },
+    })
+}
+
+function handleOptions(el, Alpine) {
+    Alpine.bind(el, {
+        'x-ref': '__options',
+        ':id'() { return this.$id('alpine-listbox-options') },
+        'x-init'() {
+            this.$data.__isStatic = Alpine.bound(this.$el, 'static', false)
+        },
+        'x-show'() { return this.$data.__isStatic ? true : this.$data.__isOpen },
+        '@click.outside'() { this.$data.__close() },
+        '@keydown.escape.stop.prevent'() { this.$data.__close() },
+        tabindex: '0',
+        'role': 'listbox',
+        ':aria-orientation'() {
+            return this.$data.__orientation
+        },
+        ':aria-labelledby'() { return this.$id('alpine-listbox-button') },
+        ':aria-activedescendant'() { return this.__context.activeEl() && this.__context.activeEl().id },
+        '@focus'() { this.__context.activateSelectedOrFirst() },
+        'x-trap'() { return this.$data.__isOpen },
+        '@keydown'(e) { this.__context.activateByKeyEvent(e) },
+        '@keydown.enter.stop.prevent'() {
+            this.__context.selectActive();
+
+            this.$data.__isMultiple || this.$data.__close()
+        },
+        '@keydown.space.stop.prevent'() {
+            this.__context.selectActive();
+
+            this.$data.__isMultiple || this.$data.__close()
+        },
+    })
+}
+
+function handleOption(el, Alpine) {
+    Alpine.bind(el, () => {
+        return {
+            ':id'() { return this.$id('alpine-listbox-option') },
+            ':tabindex'() { return this.$listbox.isDisabled ? false : '-1' },
+            'role': 'option',
+            'x-init'() {
+                queueMicrotask(() => {
+                    let value = Alpine.bound(el, 'value')
+                    let disabled = Alpine.bound(el, 'disabled')
+
+                    el.__optionKey = this.$data.__context.initItem(el, value, disabled)
+                })
+            },
+            ':aria-selected'() { return this.$listboxOption.isSelected },
+            '@click'() {
+                if (this.$listboxOption.isDisabled) return;
+                this.$data.__context.selectEl(el);
+                this.$data.__isMultiple || this.$data.__close()
+            },
+            '@mousemove'() { this.$data.__context.activateEl(el) },
+            '@mouseleave'() { this.$data.__context.deactivate() },
+        }
+    })
+}

+ 229 - 0
packages/ui/src/menu.js

@@ -0,0 +1,229 @@
+export default function (Alpine) {
+    Alpine.directive('menu', (el, directive) => {
+        if (! directive.value) handleRoot(el, Alpine)
+        else if (directive.value === 'items') handleItems(el, Alpine)
+        else if (directive.value === 'item') handleItem(el, Alpine)
+        else if (directive.value === 'button') handleButton(el, Alpine)
+    });
+
+    Alpine.magic('menuItem', el => {
+        let $data = Alpine.$data(el)
+
+        return {
+            get isActive() {
+                return $data.__activeEl == $data.__itemEl
+            },
+            get isDisabled() {
+                return el.__isDisabled.value
+            },
+        }
+    })
+}
+
+function handleRoot(el, Alpine) {
+    Alpine.bind(el, {
+        'x-id'() { return ['alpine-menu-button', 'alpine-menu-items'] },
+        'x-modelable': '__isOpen',
+        'x-data'() {
+            return {
+                __itemEls: [],
+                __activeEl: null,
+                __isOpen: false,
+                __open() {
+                    this.__isOpen = true
+
+                    // Safari needs more of a "tick" for focusing after x-show for some reason.
+                    // Probably because Alpine adds an extra tick when x-showing for @click.outside
+                    let nextTick = callback => requestAnimationFrame(() => requestAnimationFrame(callback))
+
+                    nextTick(() => this.$refs.__items.focus({ preventScroll: true }))
+                },
+                __close(focusAfter = true) {
+                    this.__isOpen = false
+
+                    focusAfter && this.$nextTick(() => this.$refs.__button.focus({ preventScroll: true }))
+                },
+                __contains(outer, inner) {
+                    return !! Alpine.findClosest(inner, el => el.isSameNode(outer))
+                }
+            }
+        },
+        '@focusin.window'() {
+            if (! this.$data.__contains(this.$el, document.activeElement)) {
+                this.$data.__close(false)
+            }
+        },
+    })
+}
+
+function handleButton(el, Alpine) {
+    Alpine.bind(el, {
+        'x-ref': '__button',
+        'aria-haspopup': 'true',
+        ':aria-labelledby'() { return this.$id('alpine-menu-label') },
+        ':id'() { return this.$id('alpine-menu-button') },
+        ':aria-expanded'() { return this.$data.__isOpen },
+        ':aria-controls'() { return this.$data.__isOpen && this.$id('alpine-menu-items') },
+        'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && ! this.$el.hasAttribute('type')) this.$el.type = 'button' },
+        '@click'() { this.$data.__open() },
+        '@keydown.down.stop.prevent'() { this.$data.__open() },
+        '@keydown.up.stop.prevent'() { this.$data.__open(dom.Alpine, last) },
+        '@keydown.space.stop.prevent'() { this.$data.__open() },
+        '@keydown.enter.stop.prevent'() { this.$data.__open() },
+    })
+}
+
+function handleItems(el, Alpine) {
+    Alpine.bind(el, {
+        'x-ref': '__items',
+        'aria-orientation': 'vertical',
+        'role': 'menu',
+        ':id'() { return this.$id('alpine-menu-items') },
+        ':aria-labelledby'() { return this.$id('alpine-menu-button') },
+        ':aria-activedescendant'() { return this.$data.__activeEl && this.$data.__activeEl.id },
+        'x-show'() { return this.$data.__isOpen },
+        'tabindex': '0',
+        '@click.outside'() { this.$data.__close() },
+        '@keydown'(e) { dom.search(Alpine, this.$refs.__items, e.key, el => el.__activate()) },
+        '@keydown.down.stop.prevent'() {
+            if (this.$data.__activeEl) dom.next(Alpine, this.$data.__activeEl, el => el.__activate())
+            else dom.first(Alpine, this.$refs.__items, el => el.__activate())
+        },
+        '@keydown.up.stop.prevent'() {
+            if (this.$data.__activeEl) dom.previous(Alpine, this.$data.__activeEl, el => el.__activate())
+            else dom.last(Alpine, this.$refs.__items, el => el.__activate())
+        },
+        '@keydown.home.stop.prevent'() { dom.first(Alpine, this.$refs.__items, el => el.__activate()) },
+        '@keydown.end.stop.prevent'() { dom.last(Alpine, this.$refs.__items, el => el.__activate()) },
+        '@keydown.page-up.stop.prevent'() { dom.first(Alpine, this.$refs.__items, el => el.__activate()) },
+        '@keydown.page-down.stop.prevent'() { dom.last(Alpine, this.$refs.__items, el => el.__activate()) },
+        '@keydown.escape.stop.prevent'() { this.$data.__close() },
+        '@keydown.space.stop.prevent'() { this.$data.__activeEl && this.$data.__activeEl.click() },
+        '@keydown.enter.stop.prevent'() { this.$data.__activeEl && this.$data.__activeEl.click() },
+        // Required for firefox, event.preventDefault() in handleKeyDown for
+        // the Space key doesn't cancel the handleKeyUp, which in turn
+        // triggers a *click*.
+        '@keyup.space.prevent'() { },
+    })
+}
+
+function handleItem(el, Alpine) {
+    Alpine.bind(el, () => {
+        return {
+            'x-data'() {
+                return {
+                    __itemEl: this.$el,
+                    init() {
+                        // Add current element to element list for navigating.
+                        let els = Alpine.raw(this.$data.__itemEls)
+                        let inserted = false
+
+                        for (let i = 0; i < els.length; i++) {
+                            if (els[i].compareDocumentPosition(this.$el) & Node.DOCUMENT_POSITION_PRECEDING) {
+                                els.splice(i, 0, this.$el)
+                                inserted = true
+                                break
+                            }
+                        }
+
+                        if (! inserted) els.push(this.$el)
+
+                        this.$el.__activate = () => {
+                            this.$data.__activeEl = this.$el
+                            this.$el.scrollIntoView({ block: 'nearest' })
+                        }
+
+                        this.$el.__deactivate = () => {
+                            this.$data.__activeEl = null
+                        }
+
+
+                        this.$el.__isDisabled = Alpine.reactive({ value: false })
+
+                        queueMicrotask(() => {
+                            this.$el.__isDisabled.value = Alpine.bound(this.$el, 'disabled', false)
+                        })
+                    },
+                    destroy() {
+                        // Remove this element from the elements list.
+                        let els = this.$data.__itemEls
+                        els.splice(els.indexOf(this.$el), 1)
+                    },
+                }
+            },
+            'x-id'() { return ['alpine-menu-item'] },
+            ':id'() { return this.$id('alpine-menu-item') },
+            ':tabindex'() { return this.$el.__isDisabled.value ? false : '-1' },
+            'role': 'menuitem',
+            '@mousemove'() { this.$el.__isDisabled.value || this.$menuItem.isActive || this.$el.__activate() },
+            '@mouseleave'() { this.$el.__isDisabled.value || ! this.$menuItem.isActive || this.$el.__deactivate() },
+        }
+    })
+}
+
+let dom = {
+    first(Alpine, parent, receive = i => i, fallback = () => { }) {
+        let first = Alpine.$data(parent).__itemEls[0]
+
+        if (! first) return fallback()
+
+        if (first.tagName.toLowerCase() === 'template') {
+            return this.next(Alpine, first, receive)
+        }
+
+        if (first.__isDisabled.value) return this.next(Alpine, first, receive)
+
+        return receive(first)
+    },
+    last(Alpine, parent, receive = i => i, fallback = () => { }) {
+        let last = Alpine.$data(parent).__itemEls.slice(-1)[0]
+
+        if (! last) return fallback()
+        if (last.__isDisabled.value) return this.previous(Alpine, last, receive)
+        return receive(last)
+    },
+    next(Alpine, el, receive = i => i, fallback = () => { }) {
+        if (! el) return fallback()
+
+        let els = Alpine.$data(el).__itemEls
+        let next = els[els.indexOf(el) + 1]
+
+        if (! next) return fallback()
+        if (next.__isDisabled.value || next.tagName.toLowerCase() === 'template') return this.next(Alpine, next, receive, fallback)
+        return receive(next)
+    },
+    previous(Alpine, el, receive = i => i, fallback = () => { }) {
+        if (! el) return fallback()
+
+        let els = Alpine.$data(el).__itemEls
+        let prev = els[els.indexOf(el) - 1]
+
+        if (! prev) return fallback()
+        if (prev.__isDisabled.value || prev.tagName.toLowerCase() === 'template') return this.previous(Alpine, prev, receive, fallback)
+        return receive(prev)
+    },
+    searchQuery: '',
+    debouncedClearSearch: undefined,
+    clearSearch(Alpine) {
+        if (! this.debouncedClearSearch) {
+            this.debouncedClearSearch = Alpine.debounce(function () { this.searchQuery = '' }, 350)
+        }
+
+        this.debouncedClearSearch()
+    },
+    search(Alpine, parent, key, receiver) {
+        if (key.length > 1) return
+
+        this.searchQuery += key
+
+        let els = Alpine.raw(Alpine.$data(parent).__itemEls)
+
+        let el = els.find(el => {
+            return el.textContent.trim().toLowerCase().startsWith(this.searchQuery)
+        })
+
+        el && ! el.__isDisabled.value && receiver(el)
+
+        this.clearSearch(Alpine)
+    },
+}

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

@@ -0,0 +1,209 @@
+
+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 isOpen() {
+                return $data.__isOpenState
+            },
+            open() {
+                $data.__open()
+            },
+            close() {
+                $data.__close()
+            },
+        }
+    })
+}
+
+function handleRoot(el, Alpine) {
+    Alpine.bind(el, {
+        'x-id'() { return ['alpine-popover-button', 'alpine-popover-panel'] },
+        'x-modelable': '__isOpenState',
+        '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,
+                __isStatic: false,
+                get __isOpen() {
+                    if (this.__isStatic) return true
+
+                    return this.__isOpenState
+                },
+                __isOpenState: false,
+                __open() {
+                    this.__isOpenState = true
+
+                    this.$dispatch('__close-others', { el: this.$el })
+                },
+                __toggle() {
+                    this.__isOpenState ? this.__close() : this.__open()
+                },
+                __close(el) {
+                    if (this.__isStatic) return
+
+                    this.__isOpenState = 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.__isStatic = Alpine.bound(this.$el, 'static', false)
+            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 }
+    })
+}

+ 220 - 0
packages/ui/src/radio.js

@@ -0,0 +1,220 @@
+
+export default function (Alpine) {
+    Alpine.directive('radio', (el, directive) => {
+        if      (! directive.value)                 handleRoot(el, Alpine)
+        else if (directive.value === 'option')      handleOption(el, Alpine)
+        else if (directive.value === 'label')       handleLabel(el, Alpine)
+        else if (directive.value === 'description') handleDescription(el, Alpine)
+    })
+
+    Alpine.magic('radioOption', el => {
+        let $data = Alpine.$data(el)
+
+        return {
+            get isActive() {
+                return $data.__option === $data.__active
+            },
+            get isChecked() {
+                return $data.__option === $data.__value
+            },
+            get isDisabled() {
+                let disabled = $data.__disabled
+
+                if ($data.__rootDisabled) return true
+
+                return disabled
+            },
+        }
+    })
+}
+
+function handleRoot(el, Alpine) {
+    Alpine.bind(el, {
+        'x-modelable': '__value',
+        'x-data'() {
+            return {
+                init() {
+                    queueMicrotask(() => {
+                        this.__rootDisabled = Alpine.bound(el, 'disabled', false);
+                        this.__value = Alpine.bound(this.$el, 'default-value', false)
+                        this.__inputName = Alpine.bound(this.$el, 'name', false)
+                        this.__inputId = 'alpine-radio-'+Date.now()
+                    })
+
+                    // Add `role="none"` to all non role elements.
+                    this.$nextTick(() => {
+                        let walker = document.createTreeWalker(
+                            this.$el,
+                            NodeFilter.SHOW_ELEMENT,
+                            {
+                                acceptNode: node => {
+                                    if (node.getAttribute('role') === 'radio') 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')
+                    })
+                },
+                __value: undefined,
+                __active: undefined,
+                __rootEl: this.$el,
+                __optionValues: [],
+                __disabledOptions: new Set,
+                __optionElsByValue: new Map,
+                __hasLabel: false,
+                __hasDescription: false,
+                __rootDisabled: false,
+                __inputName: undefined,
+                __inputId: undefined,
+                __change(value) {
+                    if (this.__rootDisabled) return
+
+                    this.__value = value
+                },
+                __addOption(option, el, disabled) {
+                    // Add current element to element list for navigating.
+                    let options = Alpine.raw(this.__optionValues)
+                    let els = options.map(i => this.__optionElsByValue.get(i))
+                    let inserted = false
+
+                    for (let i = 0; i < els.length; i++) {
+                        if (els[i].compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING) {
+                            options.splice(i, 0, option)
+                            this.__optionElsByValue.set(option, el)
+                            inserted = true
+                            break
+                        }
+                    }
+
+                    if (!inserted) {
+                        options.push(option)
+                        this.__optionElsByValue.set(option, el)
+                    }
+
+                    disabled && this.__disabledOptions.add(option)
+                },
+                __isFirstOption(option) {
+                    return this.__optionValues.indexOf(option) === 0
+                },
+                __setActive(option) {
+                    this.__active = option
+                },
+                __focusOptionNext() {
+                    let option = this.__active
+                    let all = this.__optionValues.filter(i => !this.__disabledOptions.has(i))
+                    let next = all[this.__optionValues.indexOf(option) + 1]
+                    next = next || all[0]
+
+                    this.__optionElsByValue.get(next).focus()
+                    this.__change(next)
+                },
+                __focusOptionPrev() {
+                    let option = this.__active
+                    let all = this.__optionValues.filter(i => !this.__disabledOptions.has(i))
+                    let prev = all[all.indexOf(option) - 1]
+                    prev = prev || all.slice(-1)[0]
+
+                    this.__optionElsByValue.get(prev).focus()
+                    this.__change(prev)
+                },
+            }
+        },
+        'x-effect'() {
+            let value = this.__value
+
+            // Only render a hidden input if the "name" prop is passed...
+            if (! this.__inputName) return
+
+            // First remove a previously appended hidden input (if it exists)...
+            let nextEl = this.$el.nextElementSibling
+            if (nextEl && String(nextEl.id) === String(this.__inputId)) {
+                nextEl.remove()
+            }
+
+            // If the value is true, create the input and append it, otherwise,
+            // we already removed it in the previous step...
+            if (value) {
+                let input = document.createElement('input')
+
+                input.type = 'hidden'
+                input.value = value
+                input.name = this.__inputName
+                input.id = this.__inputId
+
+                this.$el.after(input)
+            }
+        },
+        'role': 'radiogroup',
+        'x-id'() { return ['alpine-radio-label', 'alpine-radio-description'] },
+        ':aria-labelledby'() { return this.__hasLabel && this.$id('alpine-radio-label') },
+        ':aria-describedby'() { return this.__hasDescription && this.$id('alpine-radio-description') },
+        '@keydown.up.prevent.stop'() { this.__focusOptionPrev() },
+        '@keydown.left.prevent.stop'() { this.__focusOptionPrev() },
+        '@keydown.down.prevent.stop'() { this.__focusOptionNext() },
+        '@keydown.right.prevent.stop'() { this.__focusOptionNext() },
+    })
+}
+
+function handleOption(el, Alpine) {
+    Alpine.bind(el, {
+        'x-data'() {
+            return {
+                init() {
+                    queueMicrotask(() => {
+                        this.__disabled = Alpine.bound(el, 'disabled', false)
+                        this.__option = Alpine.bound(el, 'value')
+                        this.$data.__addOption(this.__option, this.$el, this.__disabled)
+                    })
+                },
+                __option: undefined,
+                __disabled: false,
+                __hasLabel: false,
+                __hasDescription: false,
+            }
+        },
+        'x-id'() { return ['alpine-radio-label', 'alpine-radio-description'] },
+        'role': 'radio',
+        ':aria-checked'() { return this.$radioOption.isChecked },
+        ':aria-disabled'() { return this.$radioOption.isDisabled },
+        ':aria-labelledby'() { return this.__hasLabel && this.$id('alpine-radio-label') },
+        ':aria-describedby'() { return this.__hasDescription && this.$id('alpine-radio-description') },
+        ':tabindex'()   {
+            if (this.$radioOption.isDisabled) return -1
+            if (this.$radioOption.isChecked) return 0
+            if (! this.$data.__value && this.$data.__isFirstOption(this.$data.__option)) return 0
+
+            return -1
+        },
+        '@click'() {
+            if (this.$radioOption.isDisabled) return
+            this.$data.__change(this.$data.__option)
+            this.$el.focus()
+        },
+        '@focus'() {
+            if (this.$radioOption.isDisabled) return
+            this.$data.__setActive(this.$data.__option)
+        },
+        '@blur'() {
+            if (this.$data.__active === this.$data.__option) this.$data.__setActive(undefined)
+        },
+        '@keydown.space.stop.prevent'() { this.$data.__change(this.$data.__option) },
+    })
+}
+
+function handleLabel(el, Alpine) {
+    Alpine.bind(el, {
+        'x-init'() { this.$data.__hasLabel = true },
+        ':id'() { return this.$id('alpine-radio-label') },
+    })
+}
+
+function handleDescription(el, Alpine) {
+    Alpine.bind(el, {
+        'x-init'() { this.$data.__hasDescription = true },
+        ':id'() { return this.$id('alpine-radio-description') },
+    })
+}

+ 116 - 0
packages/ui/src/switch.js

@@ -0,0 +1,116 @@
+
+export default function (Alpine) {
+    Alpine.directive('switch', (el, directive) => {
+        if      (directive.value === 'group')       handleGroup(el, Alpine)
+        else if (directive.value === 'label')       handleLabel(el, Alpine)
+        else if (directive.value === 'description') handleDescription(el, Alpine)
+        else                                        handleRoot(el, Alpine)
+    })
+
+    Alpine.magic('switch', el => {
+        let $data = Alpine.$data(el)
+
+        return {
+            get isChecked() {
+                return $data.__value === true
+            },
+        }
+    })
+}
+
+function handleGroup(el, Alpine) {
+    Alpine.bind(el, {
+        'x-id'() { return ['alpine-switch-label', 'alpine-switch-description'] },
+        'x-data'() {
+            return {
+                __hasLabel: false,
+                __hasDescription: false,
+                __switchEl: undefined,
+            }
+        }
+    })
+}
+
+function handleRoot(el, Alpine) {
+    Alpine.bind(el, {
+        'x-modelable': '__value',
+        'x-data'() {
+            return {
+                init() {
+                    queueMicrotask(() => {
+                        this.__value = Alpine.bound(this.$el, 'default-checked', false)
+                        this.__inputName = Alpine.bound(this.$el, 'name', false)
+                        this.__inputValue = Alpine.bound(this.$el, 'value', 'on')
+                        this.__inputId = 'alpine-switch-'+Date.now()
+                    })
+                },
+                __value: undefined,
+                __inputName: undefined,
+                __inputValue: undefined,
+                __inputId: undefined,
+                __toggle() {
+                    this.__value = ! this.__value;
+                },
+            }
+        },
+        'x-effect'() {
+            let value = this.__value
+
+            // Only render a hidden input if the "name" prop is passed...
+            if (! this.__inputName) return
+
+            // First remove a previously appended hidden input (if it exists)...
+            let nextEl = this.$el.nextElementSibling
+            if (nextEl && String(nextEl.id) === String(this.__inputId)) {
+                nextEl.remove()
+            }
+
+            // If the value is true, create the input and append it, otherwise,
+            // we already removed it in the previous step...
+            if (value) {
+                let input = document.createElement('input')
+
+                input.type = 'hidden'
+                input.value = this.__inputValue
+                input.name = this.__inputName
+                input.id = this.__inputId
+
+                this.$el.after(input)
+            }
+        },
+        'x-init'() {
+            if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button'
+            this.$data.__switchEl = this.$el
+        },
+        'role': 'switch',
+        'tabindex': "0",
+        ':aria-checked'() { return !!this.__value },
+        ':aria-labelledby'() { return this.$data.__hasLabel && this.$id('alpine-switch-label') },
+        ':aria-describedby'() { return this.$data.__hasDescription && this.$id('alpine-switch-description') },
+        '@click.prevent'() { this.__toggle() },
+        '@keyup'(e) {
+            if (e.key !== 'Tab') e.preventDefault()
+            if (e.key === ' ') this.__toggle()
+        },
+        // This is needed so that we can "cancel" the click event when we use the `Enter` key on a button.
+        '@keypress.prevent'() { },
+    })
+}
+
+function handleLabel(el, Alpine) {
+    Alpine.bind(el, {
+        'x-init'() { this.$data.__hasLabel = true },
+        ':id'() { return this.$id('alpine-switch-label') },
+        '@click'() {
+            this.$data.__switchEl.click()
+            this.$data.__switchEl.focus({ preventScroll: true })
+        },
+    })
+}
+
+function handleDescription(el, Alpine) {
+    Alpine.bind(el, {
+        'x-init'() { this.$data.__hasDescription = true },
+        ':id'() { return this.$id('alpine-switch-description') },
+    })
+}

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

@@ -0,0 +1,141 @@
+
+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 isSelected() {
+                return $data.__selectedIndex === $data.__tabs.indexOf($data.__tabEl)
+            },
+            get isDisabled() {
+                return $data.__isDisabled
+            }
+        }
+    })
+
+    Alpine.magic('panel', el => {
+        let $data = Alpine.$data(el)
+
+        return {
+            get isSelected() {
+                return $data.__selectedIndex === $data.__panels.indexOf($data.__panelEl)
+            }
+        }
+    })
+}
+
+function handleRoot(el, Alpine) {
+    Alpine.bind(el, {
+        'x-modelable': '__selectedIndex',
+        '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) {
+    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.__tabEl.__disabled = Alpine.bound(this.$el, 'disabled', false)
+                this.__isDisabled = this.__tabEl.__disabled
+            },
+            __tabEl: undefined,
+            __isDisabled: false,
+        }},
+        '@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.isSelected ? 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.$panel.isSelected ? 0 : -1 },
+        'x-data'() { return {
+            init() {
+                this.__panelEl = this.$el
+                this.$data.__addPanel(this.$el)
+            },
+            __panelEl: undefined,
+        }},
+        'x-show'() { return this.$panel.isSelected },
+    })
+}
+

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

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

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

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

@@ -49,12 +49,12 @@ test('non-boolean attributes set to null/undefined/false are removed from the el
         </div>
     `,
     ({ get }) => {
-        get('a:nth-child(1)').should(notHaveAttribute('href'))
-        get('a:nth-child(2)').should(notHaveAttribute('href'))
-        get('a:nth-child(3)').should(notHaveAttribute('href'))
-        get('span:nth-child(1)').should(notHaveAttribute('visible'))
-        get('span:nth-child(2)').should(notHaveAttribute('visible'))
-        get('span:nth-child(3)').should(notHaveAttribute('visible'))
+        get('a:nth-of-type(1)').should(notHaveAttribute('href'))
+        get('a:nth-of-type(2)').should(notHaveAttribute('href'))
+        get('a:nth-of-type(3)').should(notHaveAttribute('href'))
+        get('span:nth-of-type(1)').should(notHaveAttribute('visible'))
+        get('span:nth-of-type(2)').should(notHaveAttribute('visible'))
+        get('span:nth-of-type(3)').should(notHaveAttribute('visible'))
     }
 )
 
@@ -439,14 +439,16 @@ test('Can retrieve Alpine bound data with global bound method',
         <div id="1" x-data foo="bar" x-text="Alpine.bound($el, 'foo')"></div>
         <div id="2" x-data :foo="'bar'" x-text="Alpine.bound($el, 'foo')"></div>
         <div id="3" x-data foo x-text="Alpine.bound($el, 'foo')"></div>
-        <div id="4" x-data x-text="Alpine.bound($el, 'foo')"></div>
-        <div id="5" x-data x-text="Alpine.bound($el, 'foo', 'bar')"></div>
+        <div id="4" x-data disabled x-text="Alpine.bound($el, 'disabled')"></div>
+        <div id="5" x-data x-text="Alpine.bound($el, 'foo')"></div>
+        <div id="6" x-data x-text="Alpine.bound($el, 'foo', 'bar')"></div>
     `,
     ({ get }) => {
         get('#1').should(haveText('bar'))
         get('#2').should(haveText('bar'))
         get('#3').should(haveText('true'))
-        get('#4').should(haveText(''))
-        get('#5').should(haveText('bar'))
+        get('#4').should(haveText('true'))
+        get('#5').should(haveText(''))
+        get('#6').should(haveText('bar'))
     }
 )

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

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

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

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

+ 19 - 0
tests/cypress/integration/directives/x-model.spec.js

@@ -110,3 +110,22 @@ test('x-model can be accessed programmatically',
         get('span').should(haveText('bob'))
     }
 )
+
+test('x-model updates value when the form is reset',
+    html`
+    <div x-data="{ foo: '' }">
+        <form>
+            <input x-model="foo"></input>
+            <button type="reset">Reset</button>
+        </form>
+        <span x-text="foo"></span>
+    </div>
+    `,
+    ({ get }) => {
+        get('span').should(haveText(''))
+        get('input').type('baz')
+        get('span').should(haveText('baz'))
+        get('button').click()
+        get('span').should(haveText(''))
+    }
+)

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

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

+ 14 - 0
tests/cypress/integration/plugins/mask.spec.js

@@ -144,6 +144,20 @@ test('$money swapping commas and periods',
     },
 )
 
+test('$money with different thousands separator',
+    [html`<input x-data x-mask:function="$money($input, '.', ' ')" />`],
+    ({ get }) => {
+        get('input').type('3000').should(haveValue('3 000'));
+        get('input').type('{backspace}').blur().should(haveValue('300'));
+        get('input').type('5').should(haveValue('3 005'));
+        get('input').type('{selectAll}{backspace}').should(haveValue(''));
+        get('input').type('123').should(haveValue('123'));
+        get('input').type('4').should(haveValue('1 234'));
+        get('input').type('567').should(haveValue('1 234 567'));
+        get('input').type('.89').should(haveValue('1 234 567.89'));
+    }
+);
+
 test('$money works with permenant inserted at beginning',
     [html`<input x-data x-mask:dynamic="$money">`],
     ({ get }) => {

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

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

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

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

+ 102 - 0
tests/cypress/integration/plugins/ui/disclosure.spec.js

@@ -0,0 +1,102 @@
+import { beVisible, haveClasses, haveAttribute, html, notBeVisible, notHaveClasses, test } from '../../../utils'
+
+test('has accessibility attributes',
+    [html`
+        <div x-data x-disclosure>
+            <button trigger x-disclosure:button>Trigger</button>
+
+            <div x-disclosure:panel panel>
+                Content
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('button').should(haveAttribute('aria-expanded', 'false'))
+        get('button').should(haveAttribute('aria-controls', 'alpine-disclosure-panel-1'))
+        get('[panel]').should(haveAttribute('id', 'alpine-disclosure-panel-1'))
+    },
+)
+
+test('it toggles',
+    [html`
+        <div x-data x-disclosure>
+            <button trigger x-disclosure:button>Trigger</button>
+
+            <div x-disclosure:panel panel>
+                Content
+
+                <button close-button type="button" @click="$disclosure.close()">Close</button>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('[panel]').should(notBeVisible())
+        get('[trigger]').click()
+        get('[panel]').should(beVisible())
+        get('[trigger]').click()
+        get('[panel]').should(notBeVisible())
+    },
+)
+
+test('$disclosure.isOpen and $disclosure.close() work',
+    [html`
+        <div x-data x-disclosure>
+            <button trigger x-disclosure:button>Trigger</button>
+
+            <div x-disclosure:panel panel :class="$disclosure.isOpen && 'open'">
+                Content
+
+                <button close-button type="button" @click="$disclosure.close()">Close</button>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('[panel]').should(notHaveClasses(['open']))
+        get('[trigger]').click()
+        get('[panel]').should(haveClasses(['open']))
+        get('[close-button]').click()
+        get('[panel]').should(notBeVisible())
+    },
+)
+
+test('can set a default open state',
+    [html`
+        <div x-data x-disclosure :default-open="true">
+            <button trigger x-disclosure:button>Trigger</button>
+
+            <div x-disclosure:panel panel>
+                Content
+
+                <button close-button type="button" @click="$disclosure.close()">Close</button>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('[panel]').should(beVisible())
+        get('[trigger]').click()
+        get('[panel]').should(notBeVisible())
+    },
+)
+
+test('it toggles using the space key',
+    [html`
+        <div x-data x-disclosure>
+            <button trigger x-disclosure:button>Trigger</button>
+
+            <div x-disclosure:panel panel>
+                Content
+
+                <button close-button type="button" @click="$disclosure.close()">Close</button>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('[panel]').should(notBeVisible())
+        get('[trigger]').click()
+        get('[panel]').should(beVisible())
+        get('[trigger]').type(' ')
+        get('[panel]').should(notBeVisible())
+        get('[trigger]').type(' ')
+        get('[panel]').should(beVisible())
+    },
+)

+ 868 - 0
tests/cypress/integration/plugins/ui/listbox.spec.js

@@ -0,0 +1,868 @@
+import { beVisible, beHidden, haveAttribute, haveClasses, notHaveClasses, haveText, html, notBeVisible, notHaveAttribute, notExist, haveFocus, test} from '../../../utils'
+
+test('it works with x-model',
+    [html`
+        <div
+            x-data="{ active: null, people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb' },
+                { id: 4, name: 'Tom Cook' },
+                { id: 5, name: 'Tanya Fox', disabled: true },
+                { id: 6, name: 'Hellen Schmidt' },
+                { id: 7, name: 'Caroline Schultz' },
+                { id: 8, name: 'Mason Heaney' },
+                { id: 9, name: 'Claudie Smitham' },
+                { id: 10, name: 'Emil Schaefer' },
+            ]}"
+            x-listbox
+            x-model="active"
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="active ? active.name : 'Select Person'"></button>
+
+            <ul x-listbox:options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('ul').should(notBeVisible())
+        get('button')
+            .should(haveText('Select Person'))
+            .click()
+        get('ul').should(beVisible())
+        get('button').click()
+        get('ul').should(notBeVisible())
+        get('button').click()
+        get('[option="2"]').click()
+        get('ul').should(notBeVisible())
+        get('button').should(haveText('Arlene Mccoy'))
+    },
+)
+
+test('it works with internal state',
+    [html`
+        <div
+            x-data="{ people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb' },
+                { id: 4, name: 'Tom Cook' },
+                { id: 5, name: 'Tanya Fox', disabled: true },
+                { id: 6, name: 'Hellen Schmidt' },
+                { id: 7, name: 'Caroline Schultz' },
+                { id: 8, name: 'Mason Heaney' },
+                { id: 9, name: 'Claudie Smitham' },
+                { id: 10, name: 'Emil Schaefer' },
+            ]}"
+            x-listbox
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="$listbox.selected ? $listbox.selected.name : 'Select Person'"></button>
+
+            <ul x-listbox:options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('ul').should(notBeVisible())
+        get('button')
+            .should(haveText('Select Person'))
+            .click()
+        get('ul').should(beVisible())
+        get('button').click()
+        get('ul').should(notBeVisible())
+        get('button').click()
+        get('[option="2"]').click()
+        get('ul').should(notBeVisible())
+        get('button').should(haveText('Arlene Mccoy'))
+    },
+)
+
+test('$listbox/$listboxOption',
+    [html`
+        <div
+            x-data="{ people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb' },
+                { id: 4, name: 'Tom Cook' },
+                { id: 5, name: 'Tanya Fox', disabled: true },
+                { id: 6, name: 'Hellen Schmidt' },
+                { id: 7, name: 'Caroline Schultz' },
+                { id: 8, name: 'Mason Heaney' },
+                { id: 9, name: 'Claudie Smitham' },
+                { id: 10, name: 'Emil Schaefer' },
+            ]}"
+            x-listbox
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="$listbox.selected ? $listbox.selected.name : 'Select Person'"></button>
+
+            <article x-text="$listbox.active?.name"></article>
+
+            <ul x-listbox:options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                            'disabled': $listboxOption.isDisabled,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('article').should(haveText(''))
+        get('[option="5"]').should(haveClasses(['disabled']))
+        get('button')
+            .should(haveText('Select Person'))
+            .click()
+        get('article').should(haveText('Wade Cooper'))
+        get('[option="1"]').should(haveClasses(['active']))
+        get('ul').type('{downarrow}')
+        get('article').should(haveText('Arlene Mccoy'))
+        get('[option="2"]').should(haveClasses(['active']))
+        get('button').should(haveText('Select Person'))
+        get('[option="2"]').click()
+        get('button').should(haveText('Arlene Mccoy'))
+        get('[option="2"]').should(haveClasses(['selected']))
+    },
+)
+
+test('"name" prop',
+    [html`
+        <div
+            x-data="{ people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb' },
+                { id: 4, name: 'Tom Cook' },
+                { id: 5, name: 'Tanya Fox', disabled: true },
+                { id: 6, name: 'Hellen Schmidt' },
+                { id: 7, name: 'Caroline Schultz' },
+                { id: 8, name: 'Mason Heaney' },
+                { id: 9, name: 'Claudie Smitham' },
+                { id: 10, name: 'Emil Schaefer' },
+            ]}"
+            x-listbox
+            name="person"
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="$listbox.selected ? $listbox.selected : 'Select Person'"></button>
+
+            <ul x-listbox:options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person.id"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('input').should(haveAttribute('value', 'null'))
+        get('button').click()
+        get('input').should(haveAttribute('value', 'null'))
+        get('[option="2"]').click()
+        get('input').should(beHidden())
+            .should(haveAttribute('name', 'person'))
+            .should(haveAttribute('value', '2'))
+            .should(haveAttribute('type', 'hidden'))
+        get('button').click()
+        get('[option="4"]').click()
+        get('input').should(beHidden())
+            .should(haveAttribute('name', 'person'))
+            .should(haveAttribute('value', '4'))
+            .should(haveAttribute('type', 'hidden'))
+    },
+);
+
+test('"name" prop with object value',
+    [html`
+        <div
+            x-data="{ people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb' },
+                { id: 4, name: 'Tom Cook' },
+                { id: 5, name: 'Tanya Fox', disabled: true },
+                { id: 6, name: 'Hellen Schmidt' },
+                { id: 7, name: 'Caroline Schultz' },
+                { id: 8, name: 'Mason Heaney' },
+                { id: 9, name: 'Claudie Smitham' },
+                { id: 10, name: 'Emil Schaefer' },
+            ]}"
+            x-listbox
+            name="person"
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="$listbox.selected ? $listbox.selected.name : 'Select Person'"></button>
+
+            <ul x-listbox:options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('input[name="person"]').should(haveAttribute('value', 'null'))
+        get('button').click()
+        get('[name="person[id]"]').should(notExist())
+        get('[option="2"]').click()
+        get('input[name="person"]').should(notExist())
+        get('[name="person[id]"]').should(beHidden())
+            .should(haveAttribute('value', '2'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="person[name]"]').should(beHidden())
+            .should(haveAttribute('value', 'Arlene Mccoy'))
+            .should(haveAttribute('type', 'hidden'))
+        get('button').click()
+        get('[option="4"]').click()
+        get('[name="person[id]"]').should(beHidden())
+            .should(haveAttribute('value', '4'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="person[name]"]').should(beHidden())
+            .should(haveAttribute('value', 'Tom Cook'))
+            .should(haveAttribute('type', 'hidden'))
+    },
+);
+
+test('"default-value" prop',
+    [html`
+        <div
+            x-data="{ people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb' },
+                { id: 4, name: 'Tom Cook' },
+                { id: 5, name: 'Tanya Fox', disabled: true },
+                { id: 6, name: 'Hellen Schmidt' },
+                { id: 7, name: 'Caroline Schultz' },
+                { id: 8, name: 'Mason Heaney' },
+                { id: 9, name: 'Claudie Smitham' },
+                { id: 10, name: 'Emil Schaefer' },
+            ]}"
+            x-listbox
+            name="person"
+            default-value="2"
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="$listbox.selected ? $listbox.selected : 'Select Person'"></button>
+
+            <ul x-listbox:options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person.id"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('input').should(beHidden())
+            .should(haveAttribute('name', 'person'))
+            .should(haveAttribute('value', '2'))
+            .should(haveAttribute('type', 'hidden'))
+    },
+);
+
+test('"multiple" prop',
+    [html`
+        <div
+            x-data="{
+                people: [
+                    { id: 1, name: 'Wade Cooper' },
+                    { id: 2, name: 'Arlene Mccoy' },
+                    { id: 3, name: 'Devon Webb' },
+                    { id: 4, name: 'Tom Cook' },
+                    { id: 5, name: 'Tanya Fox', disabled: true },
+                    { id: 6, name: 'Hellen Schmidt' },
+                    { id: 7, name: 'Caroline Schultz' },
+                    { id: 8, name: 'Mason Heaney' },
+                    { id: 9, name: 'Claudie Smitham' },
+                    { id: 10, name: 'Emil Schaefer' },
+                ]
+            }"
+            x-listbox
+            multiple
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="$listbox.selected ? $listbox.selected.join(',') : 'Select People'"></button>
+
+            <ul x-listbox:options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person.id"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('button').click()
+        get('[option="2"]').click()
+        get('ul').should(beVisible())
+        get('button').should(haveText('2'))
+        get('[option="4"]').click()
+        get('button').should(haveText('2,4'))
+        get('ul').should(beVisible())
+        get('[option="4"]').click()
+        get('button').should(haveText('2'))
+        get('ul').should(beVisible())
+    },
+);
+
+test('"multiple" and "name" props together',
+    [html`
+        <div
+            x-data="{
+                people: [
+                    { id: 1, name: 'Wade Cooper' },
+                    { id: 2, name: 'Arlene Mccoy' },
+                    { id: 3, name: 'Devon Webb' },
+                    { id: 4, name: 'Tom Cook' },
+                    { id: 5, name: 'Tanya Fox', disabled: true },
+                    { id: 6, name: 'Hellen Schmidt' },
+                    { id: 7, name: 'Caroline Schultz' },
+                    { id: 8, name: 'Mason Heaney' },
+                    { id: 9, name: 'Claudie Smitham' },
+                    { id: 10, name: 'Emil Schaefer' },
+                ]
+            }"
+            x-listbox
+            multiple
+            name="people"
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="$listbox.selected ? $listbox.selected.map(p => p.id).join(',') : 'Select People'"></button>
+
+            <ul x-listbox:options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('input[name="people"]').should(haveAttribute('value', 'null'))
+        get('button').click()
+        get('[name="people[0][id]"]').should(notExist())
+        get('[option="2"]').click()
+        get('ul').should(beVisible())
+        get('button').should(haveText('2'))
+        get('input[name="people"]').should(notExist())
+        get('[name="people[0][id]"]').should(beHidden())
+            .should(haveAttribute('value', '2'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="people[0][name]"]').should(beHidden())
+            .should(haveAttribute('value', 'Arlene Mccoy'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[option="4"]').click()
+        get('[name="people[0][id]"]').should(beHidden())
+            .should(haveAttribute('value', '2'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="people[0][name]"]').should(beHidden())
+            .should(haveAttribute('value', 'Arlene Mccoy'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="people[1][id]"]').should(beHidden())
+            .should(haveAttribute('value', '4'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="people[1][name]"]').should(beHidden())
+            .should(haveAttribute('value', 'Tom Cook'))
+            .should(haveAttribute('type', 'hidden'))
+        get('button').should(haveText('2,4'))
+        get('ul').should(beVisible())
+        get('[option="4"]').click()
+        get('[name="people[0][id]"]').should(beHidden())
+            .should(haveAttribute('value', '2'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="people[0][name]"]').should(beHidden())
+            .should(haveAttribute('value', 'Arlene Mccoy'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[name="people[1][id]"]').should(notExist())
+        get('[name="people[1][name]"]').should(notExist())
+        get('button').should(haveText('2'))
+        get('ul').should(beVisible())
+    },
+);
+
+test('keyboard controls',
+    [html`
+        <div
+            x-data="{ active: null, people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb', disabled: true },
+                { id: 4, name: 'Tom Cook' },
+                { id: 5, name: 'Tanya Fox', disabled: true },
+                { id: 6, name: 'Hellen Schmidt' },
+                { id: 7, name: 'Caroline Schultz' },
+                { id: 8, name: 'Mason Heaney' },
+                { id: 9, name: 'Claudie Smitham' },
+                { id: 10, name: 'Emil Schaefer' },
+            ]}"
+            x-listbox
+            x-model="active"
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="active ? active.name : 'Select Person'"></button>
+
+            <ul x-listbox:options options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('.active').should(notExist())
+        get('button').focus().type(' ')
+        get('[options]')
+            .should(beVisible())
+            .should(haveFocus())
+        get('[option="1"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .type('{downarrow}')
+        get('[option="2"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .type('{downarrow}')
+        get('[option="4"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .type('{uparrow}')
+        get('[option="2"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .type('{home}')
+        get('[option="1"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .type('{end}')
+        get('[option="10"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .type('{pageUp}')
+        get('[option="1"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .type('{pageDown}')
+        get('[option="10"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .tab()
+            .should(haveFocus())
+            .should(beVisible())
+            .tab({ shift: true })
+            .should(haveFocus())
+            .should(beVisible())
+            .type('{esc}')
+            .should(notBeVisible())
+    },
+)
+
+test('"horizontal" keyboard controls',
+    [html`
+        <div
+            x-data="{ active: null, people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb', disabled: true },
+                { id: 4, name: 'Tom Cook' },
+                { id: 5, name: 'Tanya Fox', disabled: true },
+                { id: 6, name: 'Hellen Schmidt' },
+                { id: 7, name: 'Caroline Schultz' },
+                { id: 8, name: 'Mason Heaney' },
+                { id: 9, name: 'Claudie Smitham' },
+                { id: 10, name: 'Emil Schaefer' },
+            ]}"
+            x-listbox
+            x-model="active"
+            horizontal
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="active ? active.name : 'Select Person'"></button>
+
+            <ul x-listbox:options options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('.active').should(notExist())
+        get('button').focus().type(' ')
+        get('[options]')
+            .should(haveAttribute('aria-orientation', 'horizontal'))
+            .should(beVisible())
+            .should(haveFocus())
+        get('[option="1"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .type('{rightarrow}')
+        get('[option="2"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .type('{rightarrow}')
+        get('[option="4"]')
+            .should(haveClasses(['active']))
+        get('[options]')
+            .type('{leftarrow}')
+        get('[option="2"]')
+            .should(haveClasses(['active']))
+    },
+)
+
+test('search',
+    [html`
+        <div
+            x-data="{ active: null, people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb', disabled: true },
+                { id: 4, name: 'Tom Cook' },
+                { id: 5, name: 'Tanya Fox', disabled: true },
+                { id: 6, name: 'Hellen Schmidt' },
+                { id: 7, name: 'Caroline Schultz' },
+                { id: 8, name: 'Mason Heaney' },
+                { id: 9, name: 'Claudie Smitham' },
+                { id: 10, name: 'Emil Schaefer' },
+            ]}"
+            x-listbox
+            x-model="active"
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="active ? active.name : 'Select Person'"></button>
+
+            <ul x-listbox:options options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get, wait }) => {
+        get('.active').should(notExist())
+        get('button').click()
+        get('[options]')
+            .type('ar')
+        get('[option="2"]')
+            .should(haveClasses(['active']))
+        wait(500)
+        get('[options]')
+            .type('ma')
+        get('[option="8"]')
+            .should(haveClasses(['active']))
+    },
+)
+
+test('changing value manually changes internal state',
+    [html`
+        <div
+            x-data="{ active: null, people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb', disabled: true },
+                { id: 4, name: 'Tom Cook' },
+                { id: 5, name: 'Tanya Fox', disabled: true },
+                { id: 6, name: 'Hellen Schmidt' },
+                { id: 7, name: 'Caroline Schultz' },
+                { id: 8, name: 'Mason Heaney' },
+                { id: 9, name: 'Claudie Smitham' },
+                { id: 10, name: 'Emil Schaefer' },
+            ]}"
+            x-listbox
+            x-model="active"
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button toggle x-listbox:button x-text="$listbox.selected ? $listbox.selected : 'Select Person'"></button>
+
+            <button select-tim @click="active = 4">Select Tim</button>
+
+            <ul x-listbox:options options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person.id"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('[select-tim]').click()
+        get('[option="4"]').should(haveClasses(['selected']))
+        get('[option="1"]').should(notHaveClasses(['selected']))
+        get('[toggle]').should(haveText('4'))
+    },
+)
+
+test('has accessibility attributes',
+    [html`
+        <div
+            x-data="{ active: null, people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb', disabled: true },
+                { id: 4, name: 'Tom Cook' },
+                { id: 5, name: 'Tanya Fox', disabled: true },
+                { id: 6, name: 'Hellen Schmidt' },
+                { id: 7, name: 'Caroline Schultz' },
+                { id: 8, name: 'Mason Heaney' },
+                { id: 9, name: 'Claudie Smitham' },
+                { id: 10, name: 'Emil Schaefer' },
+            ]}"
+            x-listbox
+            x-model="active"
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button x-listbox:button x-text="active ? active.name : 'Select Person'"></button>
+
+            <ul x-listbox:options options>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                        :class="{
+                            'selected': $listboxOption.isSelected,
+                            'active': $listboxOption.isActive,
+                        }"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('button')
+            .should(haveAttribute('aria-haspopup', 'true'))
+            .should(haveAttribute('aria-labelledby', 'alpine-listbox-label-1'))
+            .should(haveAttribute('aria-expanded', 'false'))
+            .should(notHaveAttribute('aria-controls'))
+            .should(haveAttribute('id', 'alpine-listbox-button-1'))
+            .click()
+            .should(haveAttribute('aria-expanded', 'true'))
+            .should(haveAttribute('aria-controls', 'alpine-listbox-options-1'))
+
+        get('[options]')
+            .should(haveAttribute('aria-orientation', 'vertical'))
+            .should(haveAttribute('role', 'listbox'))
+            .should(haveAttribute('id', 'alpine-listbox-options-1'))
+            .should(haveAttribute('aria-labelledby', 'alpine-listbox-button-1'))
+            .should(notHaveAttribute('aria-activedescendant'))
+            .should(haveAttribute('tabindex', '0'))
+            .should(haveAttribute('aria-activedescendant', 'alpine-listbox-option-1'))
+
+        get('[option="1"]')
+            .should(haveAttribute('role', 'option'))
+            .should(haveAttribute('id', 'alpine-listbox-option-1'))
+            .should(haveAttribute('tabindex', '-1'))
+            .should(haveAttribute('aria-selected', 'false'))
+
+        get('[option="2"]')
+            .should(haveAttribute('role', 'option'))
+            .should(haveAttribute('id', 'alpine-listbox-option-2'))
+            .should(haveAttribute('tabindex', '-1'))
+            .should(haveAttribute('aria-selected', 'false'))
+
+        get('[options]')
+            .type('{downarrow}')
+            .should(haveAttribute('aria-activedescendant', 'alpine-listbox-option-2'))
+
+        get('[option="2"]')
+            .click()
+            .should(haveAttribute('aria-selected', 'true'))
+    },
+)
+
+test('"static" prop',
+    [html`
+        <div
+            x-data="{ active: null, show: false, people: [
+                { id: 1, name: 'Wade Cooper' },
+                { id: 2, name: 'Arlene Mccoy' },
+                { id: 3, name: 'Devon Webb' },
+                { id: 4, name: 'Tom Cook' },
+                { id: 5, name: 'Tanya Fox', disabled: true },
+                { id: 6, name: 'Hellen Schmidt' },
+                { id: 7, name: 'Caroline Schultz' },
+                { id: 8, name: 'Mason Heaney' },
+                { id: 9, name: 'Claudie Smitham' },
+                { id: 10, name: 'Emil Schaefer' },
+            ]}"
+            x-listbox
+            x-model="active"
+        >
+            <label x-listbox:label>Assigned to</label>
+
+            <button normal-toggle x-listbox:button x-text="active ? active.name : 'Select Person'"></button>
+
+            <button real-toggle @click="show = ! show">Toggle</button>
+
+            <ul x-listbox:options x-show="show" static>
+                <template x-for="person in people" :key="person.id">
+                    <li
+                        :option="person.id"
+                        x-listbox:option
+                        :value="person"
+                        :disabled="person.disabled"
+                    >
+                        <span x-text="person.name"></span>
+                    </li>
+                </template>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('ul').should(notBeVisible())
+        get('[normal-toggle]')
+            .should(haveText('Select Person'))
+            .click()
+        get('ul').should(notBeVisible())
+        get('[real-toggle]').click()
+        get('ul').should(beVisible())
+        get('[option="2"]').click()
+        get('ul').should(beVisible())
+        get('[normal-toggle]').should(haveText('Arlene Mccoy'))
+    },
+)
+
+// test "by" attribute

+ 392 - 0
tests/cypress/integration/plugins/ui/menu.spec.js

@@ -0,0 +1,392 @@
+import { haveClasses, beVisible, haveAttribute, haveText, html, notBeVisible, notExist, test, haveFocus, notHaveClasses, notHaveAttribute } from '../../../utils'
+
+test('it works',
+    [html`
+        <div x-data x-menu>
+            <span>
+                <button x-menu:button trigger>
+                    <span>Options</span>
+                </button>
+            </span>
+
+            <div x-menu:items items>
+                <div>
+                    <p>Signed in as</p>
+                    <p>tom@example.com</p>
+                </div>
+
+                <div>
+                    <a x-menu:item href="#account-settings">
+                        Account settings
+                    </a>
+                    <a x-menu:item href="#support">
+                        Support
+                    </a>
+                    <a x-menu:item disabled href="#new-feature">
+                        New feature (soon)
+                    </a>
+                    <a x-menu:item href="#license">
+                        License
+                    </a>
+                </div>
+                <div>
+                    <a x-menu:item href="#sign-out">
+                        Sign out
+                    </a>
+                </div>
+            </div>
+        </div>`],
+    ({ get }) => {
+        get('[items]').should(notBeVisible())
+        get('[trigger]').click()
+        get('[items]').should(beVisible())
+    },
+)
+
+test('focusing away closes menu',
+    [html`
+    <div>
+        <div x-data x-menu>
+            <span>
+                <button x-menu:button trigger>
+                    <span>Options</span>
+                </button>
+            </span>
+            <div x-menu:items items>
+                <div>
+                    <p>Signed in as</p>
+                    <p>tom@example.com</p>
+                </div>
+                <div>
+                    <a x-menu:item href="#account-settings">
+                        Account settings
+                    </a>
+                    <a x-menu:item href="#support">
+                        Support
+                    </a>
+                    <a x-menu:item href="#license">
+                        License
+                    </a>
+                </div>
+                <div>
+                    <a x-menu:item href="#sign-out">
+                        Sign out
+                    </a>
+                </div>
+            </div>
+        </div>
+
+        <button>Focus away</button>
+    </div>
+    `],
+    ({ get }) => {
+        get('[items]').should(notBeVisible())
+        get('[trigger]').click()
+        get('[items]').should(beVisible())
+        cy.focused().tab()
+        get('[items]').should(notBeVisible())
+    },
+)
+
+test('it works with x-model',
+    [html`
+        <div x-data="{ open: false }" x-menu x-model="open">
+            <button trigger @click="open = !open">
+                <span>Options</span>
+            </button>
+
+            <button x-menu:button>
+                <span>Options</span>
+            </button>
+
+            <div x-menu:items items>
+                <div>
+                    <p>Signed in as</p>
+                    <p>tom@example.com</p>
+                </div>
+
+                <div>
+                    <a x-menu:item href="#account-settings">
+                        Account settings
+                    </a>
+                    <a x-menu:item href="#support">
+                        Support
+                    </a>
+                    <a x-menu:item disabled href="#new-feature">
+                        New feature (soon)
+                    </a>
+                    <a x-menu:item href="#license">
+                        License
+                    </a>
+                </div>
+                <div>
+                    <a x-menu:item href="#sign-out">
+                        Sign out
+                    </a>
+                </div>
+            </div>
+        </div>`],
+    ({ get }) => {
+        get('[items]').should(notBeVisible())
+        get('[trigger]').click()
+        get('[items]').should(beVisible())
+        get('[trigger]').click()
+        get('[items]').should(notBeVisible())
+    },
+)
+
+test('keyboard controls',
+    [html`
+        <div x-data x-menu>
+            <span>
+                <button x-menu:button trigger>
+                    <span>Options</span>
+                </button>
+            </span>
+
+            <div x-menu:items items>
+                <div>
+                    <p>Signed in as</p>
+                    <p>tom@example.com</p>
+                </div>
+
+                <div>
+                    <a x-menu:item href="#account-settings" :class="$menuItem.isActive && 'active'">
+                        Account settings
+                    </a>
+                    <a x-menu:item href="#support" :class="$menuItem.isActive && 'active'">
+                        Support
+                    </a>
+                    <a x-menu:item disabled href="#new-feature" :class="$menuItem.isActive && 'active'">
+                        New feature (soon)
+                    </a>
+                    <a x-menu:item href="#license" :class="$menuItem.isActive && 'active'">
+                        License
+                    </a>
+                </div>
+                <div>
+                    <a x-menu:item href="#sign-out" :class="$menuItem.isActive && 'active'">
+                        Sign out
+                    </a>
+                </div>
+            </div>
+        </div>`],
+    ({ get }) => {
+        get('.active').should(notExist())
+        get('[trigger]').type(' ')
+        get('[items]')
+            .should(beVisible())
+            .should(haveFocus())
+            .type('{downarrow}')
+        get('[href="#account-settings"]')
+            .should(haveClasses(['active']))
+        get('[items]')
+            .type('{downarrow}')
+        get('[href="#support"]')
+            .should(haveClasses(['active']))
+            .type('{downarrow}')
+        get('[href="#license"]')
+            .should(haveClasses(['active']))
+        get('[items]')
+            .type('{uparrow}')
+        get('[href="#support"]')
+            .should(haveClasses(['active']))
+        get('[items]')
+            .type('{home}')
+        get('[href="#account-settings"]')
+            .should(haveClasses(['active']))
+        get('[items]')
+            .type('{end}')
+        get('[href="#sign-out"]')
+            .should(haveClasses(['active']))
+        get('[items]')
+            .type('{pageUp}')
+        get('[href="#account-settings"]')
+            .should(haveClasses(['active']))
+        get('[items]')
+            .type('{pageDown}')
+        get('[href="#sign-out"]')
+            .should(haveClasses(['active']))
+        get('[items]')
+            .tab()
+            .should(haveFocus())
+            .should(beVisible())
+            .tab({ shift: true})
+            .should(haveFocus())
+            .should(beVisible())
+            .type('{esc}')
+            .should(notBeVisible())
+    },
+)
+
+test('search',
+    [html`
+        <div x-data x-menu>
+            <span>
+                <button x-menu:button trigger>
+                    <span>Options</span>
+                </button>
+            </span>
+
+            <div x-menu:items items>
+                <div>
+                    <p>Signed in as</p>
+                    <p>tom@example.com</p>
+                </div>
+
+                <div>
+                    <a x-menu:item href="#account-settings" :class="$menuItem.isActive && 'active'">
+                        Account settings
+                    </a>
+                    <a x-menu:item href="#support" :class="$menuItem.isActive && 'active'">
+                        Support
+                    </a>
+                    <a x-menu:item disabled href="#new-feature" :class="$menuItem.isActive && 'active'">
+                        New feature (soon)
+                    </a>
+                    <a x-menu:item href="#license" :class="$menuItem.isActive && 'active'">
+                        License
+                    </a>
+                </div>
+                <div>
+                    <a x-menu:item href="#sign-out" :class="$menuItem.isActive && 'active'">
+                        Sign out
+                    </a>
+                </div>
+            </div>
+        </div>`],
+    ({ get, wait }) => {
+        get('.active').should(notExist())
+        get('[trigger]').click()
+        get('[items]')
+            .type('ac')
+        get('[href="#account-settings"]')
+            .should(haveClasses(['active']))
+        wait(500)
+        get('[items]')
+            .type('si')
+        get('[href="#sign-out"]')
+            .should(haveClasses(['active']))
+    },
+)
+
+test('has accessibility attributes',
+    [html`
+        <div x-data x-menu>
+            <label x-menu:label>Options label</label>
+
+            <span>
+                <button x-menu:button trigger>
+                    <span>Options</span>
+                </button>
+            </span>
+
+            <div x-menu:items items>
+                <div>
+                    <p>Signed in as</p>
+                    <p>tom@example.com</p>
+                </div>
+
+                <div>
+                    <a x-menu:item href="#account-settings" :class="$menuItem.isActive && 'active'">
+                        Account settings
+                    </a>
+                    <a x-menu:item href="#support" :class="$menuItem.isActive && 'active'">
+                        Support
+                    </a>
+                    <a x-menu:item disabled href="#new-feature" :class="$menuItem.isActive && 'active'">
+                        New feature (soon)
+                    </a>
+                    <a x-menu:item href="#license" :class="$menuItem.isActive && 'active'">
+                        License
+                    </a>
+                </div>
+                <div>
+                    <a x-menu:item href="#sign-out" :class="$menuItem.isActive && 'active'">
+                        Sign out
+                    </a>
+                </div>
+            </div>
+        </div>`],
+    ({ get }) => {
+        get('[trigger]')
+            .should(haveAttribute('aria-haspopup', 'true'))
+            .should(haveAttribute('aria-labelledby', 'alpine-menu-label-1'))
+            .should(haveAttribute('aria-expanded', 'false'))
+            .should(notHaveAttribute('aria-controls'))
+            .should(haveAttribute('id', 'alpine-menu-button-1'))
+            .click()
+            .should(haveAttribute('aria-expanded', 'true'))
+            .should(haveAttribute('aria-controls', 'alpine-menu-items-1'))
+
+        get('[items]')
+            .should(haveAttribute('aria-orientation', 'vertical'))
+            .should(haveAttribute('role', 'menu'))
+            .should(haveAttribute('id', 'alpine-menu-items-1'))
+            .should(haveAttribute('aria-labelledby', 'alpine-menu-button-1'))
+            .should(notHaveAttribute('aria-activedescendant'))
+            .should(haveAttribute('tabindex', '0'))
+            .type('{downarrow}')
+            .should(haveAttribute('aria-activedescendant', 'alpine-menu-item-1'))
+
+        get('[href="#account-settings"]')
+            .should(haveAttribute('role', 'menuitem'))
+            .should(haveAttribute('id', 'alpine-menu-item-1'))
+            .should(haveAttribute('tabindex', '-1'))
+
+        get('[href="#support"]')
+            .should(haveAttribute('role', 'menuitem'))
+            .should(haveAttribute('id', 'alpine-menu-item-2'))
+            .should(haveAttribute('tabindex', '-1'))
+
+        get('[items]')
+            .type('{downarrow}')
+            .should(haveAttribute('aria-activedescendant', 'alpine-menu-item-2'))
+    },
+)
+
+test('$menuItem.isDisabled',
+    [html`
+        <div x-data x-menu>
+            <label x-menu:label>Options label</label>
+
+            <span>
+                <button x-menu:button trigger>
+                    <span>Options</span>
+                </button>
+            </span>
+
+            <div x-menu:items items>
+                <div>
+                    <p>Signed in as</p>
+                    <p>tom@example.com</p>
+                </div>
+
+                <div>
+                    <a x-menu:item href="#account-settings" :class="{ 'active': $menuItem.isActive, 'disabled': $menuItem.isDisabled }">
+                        Account settings
+                    </a>
+                    <a x-menu:item href="#support" :class="{ 'active': $menuItem.isActive, 'disabled': $menuItem.isDisabled }">
+                        Support
+                    </a>
+                    <a x-menu:item disabled href="#new-feature" :class="{ 'active': $menuItem.isActive, 'disabled': $menuItem.isDisabled }">
+                        New feature (soon)
+                    </a>
+                    <a x-menu:item href="#license" :class="{ 'active': $menuItem.isActive, 'disabled': $menuItem.isDisabled }">
+                        License
+                    </a>
+                </div>
+                <div>
+                    <a x-menu:item href="#sign-out" :class="{ 'active': $menuItem.isActive, 'disabled': $menuItem.isDisabled }">
+                        Sign out
+                    </a>
+                </div>
+            </div>
+        </div>`],
+    ({ get }) => {
+        get('[trigger]').click()
+        get('[href="#account-settings"]').should(notHaveClasses(['disabled']))
+        get('[href="#support"]').should(notHaveClasses(['disabled']))
+        get('[href="#new-feature"]').should(haveClasses(['disabled']))
+    },
+)

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

@@ -0,0 +1,190 @@
+import { beVisible, haveAttribute, html, notBeVisible, notHaveAttribute, test } from '../../../utils'
+
+test('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('popover can be rendered statically',
+    [html`
+        <div x-data x-popover>
+            <button x-popover:button>Toggle</button>
+
+            <ul x-popover:panel static>
+                Dialog Contents!
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('ul').should(beVisible())
+        get('button').click()
+        get('ul').should(beVisible())
+    },
+)
+
+test('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('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('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('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('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())
+    },
+)
+
+test('focusing away still closes panel inside a group if the focus attribute is present',
+    [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 focus>
+                        <a href="#">Dialog 1 Contents!</a>
+                    </ul>
+                </div>
+                <div x-data x-popover id="2">
+                    <button x-popover:button>Toggle 2</button>
+                    <ul x-popover:panel>
+                        <a href="#">Dialog 2 Contents!</a>
+                    </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(notBeVisible())
+        get('#2 ul').should(notBeVisible())
+    },
+)

+ 513 - 0
tests/cypress/integration/plugins/ui/radio.spec.js

@@ -0,0 +1,513 @@
+import { haveAttribute, haveFocus, html, haveClasses, notHaveClasses, test, haveText, notExist, beHidden, } from '../../../utils'
+
+test('it works using x-model',
+    [html`
+        <main x-data="{ active: null, access: [
+            {
+                id: 'access-1',
+                name: 'Public access',
+                description: 'This project would be available to anyone who has the link',
+                disabled: false,
+            },
+            {
+                id: 'access-2',
+                name: 'Private to Project Members',
+                description: 'Only members of this project would be able to access',
+                disabled: false,
+            },
+            {
+                id: 'access-3',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: true,
+            },
+            {
+                id: 'access-4',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: false,
+            },
+        ]}">
+            <div x-radio x-model="active">
+                <fieldset>
+                    <legend>
+                        <h2 x-radio:label>Privacy setting</h2>
+                    </legend>
+
+                    <div>
+                        <template x-for="({ id, name, description, disabled }, i) in access" :key="id">
+                            <div :option="id" x-radio:option :value="id" :disabled="disabled">
+                                <span x-radio:label x-text="name"></span>
+                                <span x-radio:description x-text="description"></span>
+                            </div>
+                        </template>
+                    </div>
+                </fieldset>
+            </div>
+
+            <input x-model="active" type="hidden">
+        </main>
+    `],
+    ({ get }) => {
+        get('input').should(haveAttribute('value', ''))
+        get('[option="access-2"]').click()
+        get('input').should(haveAttribute('value', 'access-2'))
+        get('[option="access-4"]').click()
+        get('input').should(haveAttribute('value', 'access-4'))
+    },
+)
+
+test('it works without x-model/with default-value',
+    [html`
+        <main x-data="{ access: [
+            {
+                id: 'access-1',
+                name: 'Public access',
+                description: 'This project would be available to anyone who has the link',
+                disabled: false,
+            },
+            {
+                id: 'access-2',
+                name: 'Private to Project Members',
+                description: 'Only members of this project would be able to access',
+                disabled: false,
+            },
+            {
+                id: 'access-3',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: true,
+            },
+            {
+                id: 'access-4',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: false,
+            },
+        ]}">
+            <div x-radio default-value="access-4">
+                <fieldset>
+                    <legend>
+                        <h2 x-radio:label>Privacy setting</h2>
+                    </legend>
+
+                    <div>
+                        <template x-for="({ id, name, description, disabled }, i) in access" :key="id">
+                            <div :option="id" x-radio:option :value="id" :disabled="disabled">
+                                <span x-radio:label x-text="name"></span>
+                                <span x-radio:description x-text="description"></span>
+                            </div>
+                        </template>
+                    </div>
+                </fieldset>
+            </div>
+        </main>
+    `],
+    ({ get }) => {
+        get('[option="access-4"]').should(haveAttribute('aria-checked', 'true'))
+        get('[option="access-2"]').click()
+        get('[option="access-2"]').should(haveAttribute('aria-checked', 'true'))
+    },
+)
+
+test('cannot select any option when the group is disabled',
+    [html`
+        <main x-data="{ active: null, access: [
+            {
+                id: 'access-1',
+                name: 'Public access',
+                description: 'This project would be available to anyone who has the link',
+                disabled: false,
+            },
+            {
+                id: 'access-2',
+                name: 'Private to Project Members',
+                description: 'Only members of this project would be able to access',
+                disabled: false,
+            },
+            {
+                id: 'access-3',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: true,
+            },
+            {
+                id: 'access-4',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: false,
+            },
+        ]}">
+            <div x-radio x-model="active" disabled>
+                <template x-for="({ id, name, description, disabled }, i) in access" :key="id">
+                    <div :option="id" x-radio:option :value="id" :disabled="disabled">
+                        <span x-radio:label x-text="name"></span>
+                        <span x-radio:description x-text="description"></span>
+                    </div>
+                </template>
+            </div>
+
+            <input x-model="active" type="hidden">
+        </main>
+    `],
+    ({ get }) => {
+        get('input').should(haveAttribute('value', ''))
+        get('[option="access-1"]').click()
+        get('input').should(haveAttribute('value', ''))
+    },
+)
+
+test('cannot select a disabled option',
+    [html`
+        <main x-data="{ active: null, access: [
+            {
+                id: 'access-1',
+                name: 'Public access',
+                description: 'This project would be available to anyone who has the link',
+                disabled: false,
+            },
+            {
+                id: 'access-2',
+                name: 'Private to Project Members',
+                description: 'Only members of this project would be able to access',
+                disabled: false,
+            },
+            {
+                id: 'access-3',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: true,
+            },
+            {
+                id: 'access-4',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: false,
+            },
+        ]}">
+            <div x-radio x-model="active">
+                <template x-for="({ id, name, description, disabled }, i) in access" :key="id">
+                    <div :option="id" x-radio:option :value="id" :disabled="disabled">
+                        <span x-radio:label x-text="name"></span>
+                        <span x-radio:description x-text="description"></span>
+                    </div>
+                </template>
+            </div>
+
+            <input x-model="active" type="hidden">
+        </main>
+    `],
+    ({ get }) => {
+        get('input').should(haveAttribute('value', ''))
+        get('[option="access-3"]').click()
+        get('input').should(haveAttribute('value', ''))
+    },
+)
+
+test('keyboard navigation works',
+    [html`
+        <main x-data="{ active: null, access: [
+            {
+                id: 'access-1',
+                name: 'Public access',
+                description: 'This project would be available to anyone who has the link',
+                disabled: false,
+            },
+            {
+                id: 'access-2',
+                name: 'Private to Project Members',
+                description: 'Only members of this project would be able to access',
+                disabled: false,
+            },
+            {
+                id: 'access-3',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: true,
+            },
+            {
+                id: 'access-4',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: false,
+            },
+        ]}">
+            <div x-radio x-model="active">
+                <template x-for="({ id, name, description, disabled }, i) in access" :key="id">
+                    <div :option="id" x-radio:option :value="id" :disabled="disabled">
+                        <span x-radio:label x-text="name"></span>
+                        <span x-radio:description x-text="description"></span>
+                    </div>
+                </template>
+            </div>
+
+            <input x-model="active" type="hidden">
+        </main>
+    `],
+    ({ get }) => {
+        get('input').should(haveAttribute('value', ''))
+        get('[option="access-1"]').focus().type('{downarrow}')
+        get('[option="access-2"]').should(haveFocus()).type('{downarrow}')
+        get('[option="access-4"]').should(haveFocus()).type('{downarrow}')
+        get('[option="access-1"]').should(haveFocus())
+    },
+)
+
+test('has accessibility attributes',
+    [html`
+        <main x-data="{ active: null, access: [
+            {
+                id: 'access-1',
+                name: 'Public access',
+                description: 'This project would be available to anyone who has the link',
+                disabled: false,
+            },
+            {
+                id: 'access-2',
+                name: 'Private to Project Members',
+                description: 'Only members of this project would be able to access',
+                disabled: false,
+            },
+            {
+                id: 'access-3',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: true,
+            },
+            {
+                id: 'access-4',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: false,
+            },
+        ]}">
+            <div x-radio group x-model="active">
+                <fieldset>
+                    <legend>
+                        <h2 x-radio:label>Privacy setting</h2>
+                        <p x-radio:description>Some description</p>
+                    </legend>
+
+                    <div>
+                        <template x-for="({ id, name, description, disabled }, i) in access" :key="id">
+                            <div :option="id" x-radio:option :value="id" :disabled="disabled">
+                                <span :label="id" x-radio:label x-text="name"></span>
+                                <span :description="id" x-radio:description x-text="description"></span>
+                            </div>
+                        </template>
+                    </div>
+                </fieldset>
+            </div>
+        </main>
+    `],
+    ({ get }) => {
+        get('[group]').should(haveAttribute('role', 'radiogroup'))
+            .should(haveAttribute('aria-labelledby', 'alpine-radio-label-1'))
+            .should(haveAttribute('aria-describedby', 'alpine-radio-description-1'))
+        get('h2').should(haveAttribute('id', 'alpine-radio-label-1'))
+        get('p').should(haveAttribute('id', 'alpine-radio-description-1'))
+
+        get('[option="access-1"]')
+            .should(haveAttribute('tabindex', 0))
+
+        for (i in 4) {
+            get(`[option="access-${i}"]`)
+                .should(haveAttribute('role', 'radio'))
+                .should(haveAttribute('aria-disabled', 'false'))
+                .should(haveAttribute('aria-labelledby', `alpine-radio-label-${i + 1}`))
+                .should(haveAttribute('aria-describedby', `alpine-radio-description-${i + 1}`))
+            get(`[label="access-${i}"]`)
+                .should(haveAttribute('id', `alpine-radio-label-${i + 1}`))
+            get(`[description="access-${i}"]`)
+                .should(haveAttribute('id', `alpine-radio-description-${i + 1}`))
+        }
+
+        get('[option="access-1"]')
+            .click()
+            .should(haveAttribute('aria-checked', 'true'))
+    },
+)
+
+test('$radioOption.isActive, $radioOption.isChecked, $radioOption.isDisabled work',
+    [html`
+        <main x-data="{ access: [
+            {
+                id: 'access-1',
+                name: 'Public access',
+                description: 'This project would be available to anyone who has the link',
+                disabled: false,
+            },
+            {
+                id: 'access-2',
+                name: 'Private to Project Members',
+                description: 'Only members of this project would be able to access',
+                disabled: false,
+            },
+            {
+                id: 'access-3',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: true,
+            },
+            {
+                id: 'access-4',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: false,
+            },
+        ]}">
+            <div x-radio group>
+                <template x-for="({ id, name, description, disabled }, i) in access" :key="id">
+                    <div
+                        :option="id"
+                        x-radio:option
+                        :value="id"
+                        :disabled="disabled"
+                        :class="{
+                            'active': $radioOption.isActive,
+                            'checked': $radioOption.isChecked,
+                            'disabled': $radioOption.isDisabled,
+                        }"
+                    >
+                        <span :label="id" x-radio:label x-text="name"></span>
+                        <span :description="id" x-radio:description x-text="description"></span>
+                    </div>
+                </template>
+            </div>
+        </main>
+    `],
+    ({ get }) => {
+        get('[option="access-1"]')
+            .should(notHaveClasses(['active', 'checked', 'disabled']))
+            .focus()
+            .should(haveClasses(['active']))
+            .should(notHaveClasses(['checked']))
+            .type(' ')
+            .should(haveClasses(['active', 'checked']))
+            .type('{downarrow}')
+        get('[option="access-2"]')
+            .should(haveClasses(['active', 'checked']))
+        get('[option="access-3"]')
+            .should(haveClasses(['disabled']))
+    },
+)
+
+test('can bind objects to the value',
+    [html`
+        <main x-data="{ active: null, access: [
+            {
+                id: 'access-1',
+                name: 'Public access',
+                description: 'This project would be available to anyone who has the link',
+                disabled: false,
+            },
+            {
+                id: 'access-2',
+                name: 'Private to Project Members',
+                description: 'Only members of this project would be able to access',
+                disabled: false,
+            },
+            {
+                id: 'access-3',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: true,
+            },
+            {
+                id: 'access-4',
+                name: 'Private to you',
+                description: 'You are the only one able to access this project',
+                disabled: false,
+            },
+        ]}">
+            <div x-radio group x-model="active">
+                <template x-for="(option, i) in access" :key="option.id">
+                    <div
+                        :option="option.id"
+                        x-radio:option
+                        :value="option"
+                        :disabled="option.disabled"
+                    >
+                        <span :label="option.id" x-radio:label x-text="option.name"></span>
+                        <span :description="option.id" x-radio:description x-text="option.description"></span>
+                    </div>
+                </template>
+            </div>
+
+            <article x-text="JSON.stringify(active)"></article>
+        </main>
+    `],
+    ({ get }) => {
+        get('[option="access-2"]').click()
+        get('article')
+            .should(haveText(JSON.stringify({
+                id: 'access-2',
+                name: 'Private to Project Members',
+                description: 'Only members of this project would be able to access',
+                disabled: false,
+            })))
+    },
+)
+
+test('name prop',
+    [html`
+        <main
+            x-data="{
+                active: null,
+                access: [
+                    {
+                        id: 'access-1',
+                        name: 'Public access',
+                        description: 'This project would be available to anyone who has the link',
+                        disabled: false,
+                    },
+                    {
+                        id: 'access-2',
+                        name: 'Private to Project Members',
+                        description: 'Only members of this project would be able to access',
+                        disabled: false,
+                    },
+                    {
+                        id: 'access-3',
+                        name: 'Private to you',
+                        description: 'You are the only one able to access this project',
+                        disabled: true,
+                    },
+                    {
+                        id: 'access-4',
+                        name: 'Private to you',
+                        description: 'You are the only one able to access this project',
+                        disabled: false,
+                    },
+                ]
+            }
+        ">
+            <div x-radio group x-model="active" name="access">
+                <template x-for="({ id, name, description, disabled }, i) in access" :key="id">
+                    <div
+                        :option="id"
+                        x-radio:option
+                        :value="id"
+                        :disabled="disabled"
+                    >
+                        <span :label="id" x-radio:label x-text="name"></span>
+                        <span :description="id" x-radio:description x-text="description"></span>
+                    </div>
+                </template>
+            </div>
+        </main>
+    `],
+    ({ get }) => {
+        get('input').should(notExist())
+        get('[option="access-2"]').click()
+        get('input').should(beHidden())
+            .should(haveAttribute('name', 'access'))
+            .should(haveAttribute('value', 'access-2'))
+            .should(haveAttribute('type', 'hidden'))
+        get('[option="access-4"]').click()
+        get('input').should(beHidden())
+            .should(haveAttribute('name', 'access'))
+            .should(haveAttribute('value', 'access-4'))
+            .should(haveAttribute('type', 'hidden'))
+    },
+)

+ 151 - 0
tests/cypress/integration/plugins/ui/switch.spec.js

@@ -0,0 +1,151 @@
+import { beHidden, beVisible, haveAttribute, haveClasses, haveText, html, notBeVisible, notExist, test } from '../../../utils'
+
+test('has accessibility attributes',
+    [html`
+        <div x-data="{ checked: false }">
+            <article x-switch:group>
+                <label x-switch:label>Enable notifications</label>
+                <span description x-switch:description>A description of the switch.</span>
+
+                <button x-switch x-model="checked">Enable Notifications</button>
+            </article>
+        </div>
+    `],
+    ({ get }) => {
+        get('label').should(haveAttribute('id', 'alpine-switch-label-1'))
+        get('[description]').should(haveAttribute('id', 'alpine-switch-description-1'))
+        get('button').should(haveAttribute('type', 'button'))
+        get('button').should(haveAttribute('aria-labelledby', 'alpine-switch-label-1'))
+        get('button').should(haveAttribute('aria-describedby', 'alpine-switch-description-1'))
+        get('button').should(haveAttribute('role', 'switch'))
+        get('button').should(haveAttribute('tabindex', 0))
+        get('button').should(haveAttribute('aria-checked', 'false'))
+        get('button').click()
+        get('button').should(haveAttribute('aria-checked', 'true'))
+    },
+)
+
+test('works with x-model',
+    [html`
+        <div x-data="{ checked: false }">
+            <button x-switch x-model="checked">Enable notifications</button>
+
+            <article x-show="checked">
+                Notifications are enabled.
+            </article>
+        </div>
+    `],
+    ({ get }) => {
+        get('article').should(notBeVisible())
+        get('button').click()
+        get('article').should(beVisible())
+        get('button').click()
+        get('article').should(notBeVisible())
+    },
+)
+
+test('works with internal state/$switch.isChecked',
+    [html`
+        <div x-data>
+            <button x-switch x-bind:class="$switch.isChecked ? 'foo' : 'bar'">
+                Enable notifications
+            </button>
+        </div>
+    `],
+    ({ get }) => {
+        get('button').should(haveClasses(['bar']))
+        get('button').click()
+        get('button').should(haveClasses(['foo']))
+        get('button').click()
+        get('button').should(haveClasses(['bar']))
+    },
+)
+
+test('pressing space toggles the switch',
+    [html`
+        <div x-data="{ checked: false }">
+            <div>
+                <button x-switch x-model="checked">Enable notifications</button>
+
+                <article x-show="checked">
+                    Notifications are enabled.
+                </article>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('article').should(notBeVisible())
+        get('button').focus()
+        get('button').type(' ')
+        get('article').should(beVisible())
+        get('button').type(' ')
+        get('article').should(notBeVisible())
+    },
+)
+
+test('default-checked',
+    [html`
+        <div x-data>
+            <div>
+                <button
+                    x-switch
+                    default-checked
+                    :class="$switch.isChecked ? 'checked' : 'not-checked'"
+                >Enable notifications</button>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('button').should(haveClasses(['checked']))
+        get('button').click()
+        get('button').should(haveClasses(['not-checked']))
+    },
+)
+
+test('name and value props',
+    [html`
+        <div x-data>
+            <div>
+                <button
+                    x-switch
+                    name="notifications"
+                    value="yes"
+                >Enable notifications</button>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('input').should(notExist())
+        get('button').click()
+        get('input').should(beHidden())
+            .should(haveAttribute('name', 'notifications'))
+            .should(haveAttribute('value', 'yes'))
+            .should(haveAttribute('type', 'hidden'))
+        get('button').click()
+        get('input').should(notExist())
+    },
+)
+
+
+test('value defaults to "on"',
+    [html`
+        <div x-data>
+            <div>
+                <button
+                    x-switch
+                    name="notifications"
+                >Enable notifications</button>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('input').should(notExist())
+        get('button').click()
+        get('input').should(beHidden())
+            .should(haveAttribute('name', 'notifications'))
+            .should(haveAttribute('value', 'on'))
+            .should(haveAttribute('type', 'hidden'))
+        get('button').click()
+        get('input').should(notExist())
+    },
+)

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

@@ -0,0 +1,225 @@
+import { beVisible, haveClasses, haveFocus, html, notBeVisible, notHaveClasses, test } from '../../../utils'
+
+test('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('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('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())
+    },
+)
+
+test('can detect the selected tab & panel',
+    [html`
+        <div x-data x-tabs>
+            <div x-tabs:list>
+                <button x-tabs:tab button-1 :class="$tab.isSelected && 'active'">First</button>
+                <button x-tabs:tab button-2 :class="$tab.isSelected && 'active'">Second</button>
+            </div>
+
+            <div x-tabs:panels>
+                <div x-tabs:panel panel-1 :class="$panel.isSelected && 'active'">First Panel</div>
+                <div x-tabs:panel panel-2 :class="$panel.isSelected && 'active'">Second Panel</div>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('[panel-1]').should(beVisible())
+        get('[panel-2]').should(notBeVisible())
+        get('[button-1]').should(haveClasses(['active']))
+        get('[panel-1]').should(haveClasses(['active']))
+        get('[button-2]').should(notHaveClasses(['active']))
+        get('[panel-2]').should(notHaveClasses(['active']))
+        get('[button-2]').click()
+        get('[button-1]').should(notHaveClasses(['active']))
+        get('[panel-1]').should(notHaveClasses(['active']))
+        get('[button-2]').should(haveClasses(['active']))
+        get('[panel-2]').should(haveClasses(['active']))
+        get('[panel-1]').should(notBeVisible())
+        get('[panel-2]').should(beVisible())
+    },
+)
+
+test('can disable a tab',
+    [html`
+        <div x-data x-tabs>
+            <div x-tabs:list>
+                <button x-tabs:tab button-1>First</button>
+                <button x-tabs:tab button-2 disabled :class="$tab.isDisabled && 'disabled'">Second</button>
+                <button x-tabs:tab button-3>Third</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 x-tabs:panel panel-3>Third Panel</div>
+            </div>
+        </div>
+    `],
+    ({ get }) => {
+        get('[button-2]').should(haveClasses(['disabled']))
+        get('[button-1]').click()
+        get('[button-1]').should(haveFocus())
+        get('[panel-1]').should(beVisible())
+        get('[panel-2]').should(notBeVisible())
+        get('[button-1]').type('{rightArrow}')
+        get('[panel-1]').should(notBeVisible())
+        get('[panel-3]').should(beVisible())
+        get('[button-3]').type('{rightArrow}')
+        get('[panel-3]').should(notBeVisible())
+        get('[panel-1]').should(beVisible())
+    },
+)
+
+test('can traverse tabs manually',
+    [html`
+        <div x-data x-tabs manual>
+            <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('[button-1]').click()
+        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(beVisible())
+        get('[panel-2]').should(notBeVisible())
+        get('[button-2]').click()
+        get('[panel-1]').should(notBeVisible())
+        get('[panel-2]').should(beVisible())
+    },
+)
+
+test('can set a default index',
+    [html`
+        <div x-data x-tabs default-index="1">
+            <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(notBeVisible())
+        get('[panel-2]').should(beVisible())
+    },
+)
+
+test('can programmatically control the selected tab',
+    [html`
+        <div x-data="{ selectedIndex: 1 }">
+            <button @click="selectedIndex = selectedIndex ? 0 : 1" button-toggle>Toggle tabs</button>
+
+            <div x-tabs x-model="selectedIndex">
+                <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>
+        </div>
+    `],
+    ({ get }) => {
+        get('[panel-1]').should(notBeVisible())
+        get('[panel-2]').should(beVisible())
+        get('[button-toggle]').click()
+        get('[panel-2]').should(notBeVisible())
+        get('[panel-1]').should(beVisible())
+        get('[button-toggle]').click()
+        get('[panel-1]').should(notBeVisible())
+        get('[panel-2]').should(beVisible())
+    },
+)

+ 1 - 0
tests/cypress/spec.html

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

+ 11 - 1
tests/cypress/utils.js

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

+ 32 - 1
tests/jest/mask.spec.js

@@ -1,4 +1,4 @@
-let { stripDown } = require('../../packages/mask/dist/module.cjs')
+let { stripDown, formatMoney } = require('../../packages/mask/dist/module.cjs');
 
 test('strip-down functionality', async () => {
     expect(stripDown('(***) ***-****', '7162256108')).toEqual('7162256108')
@@ -12,3 +12,34 @@ test('strip-down functionality', async () => {
     expect(stripDown('(999) 999-9999', '(716) 2256108')).toEqual('7162256108')
     expect(stripDown('(999) 999-9999', '(716) 2-25--6108')).toEqual('7162256108')
 })
+
+test('formatMoney functionality', async () => {
+    // Default arguments implicit and explicit
+    expect(formatMoney('123456')).toEqual('123,456');
+    expect(formatMoney('9900900')).toEqual('9,900,900');
+    expect(formatMoney('5600.40')).toEqual('5,600.40');
+    expect(formatMoney('123456', '.')).toEqual('123,456');
+    expect(formatMoney('9900900', '.')).toEqual('9,900,900');
+    expect(formatMoney('5600.40', '.')).toEqual('5,600.40');
+    expect(formatMoney('123456', '.', ',')).toEqual('123,456');
+    expect(formatMoney('9900900', '.', ',')).toEqual('9,900,900');
+    expect(formatMoney('5600.40', '.', ',')).toEqual('5,600.40');
+
+    // Switch decimal separator
+    expect(formatMoney('123456', ',')).toEqual('123.456');
+    expect(formatMoney('9900900', ',')).toEqual('9.900.900');
+    expect(formatMoney('5600.40', ',')).toEqual('5.600,40');
+    expect(formatMoney('123456', '/')).toEqual('123.456');
+    expect(formatMoney('9900900', '/')).toEqual('9.900.900');
+    expect(formatMoney('5600.40', '/')).toEqual('5.600/40');
+
+    // Switch thousands separator
+    expect(formatMoney('123456', '.', ' ')).toEqual('123 456');
+    expect(formatMoney('9900900', '.', ' ')).toEqual('9 900 900');
+    expect(formatMoney('5600.40', '.', ' ')).toEqual('5 600.40');
+
+    // Switch decimal and thousands separator
+    expect(formatMoney('123456', '#', ' ')).toEqual('123 456');
+    expect(formatMoney('9900900', '#', ' ')).toEqual('9 900 900');
+    expect(formatMoney('5600.40', '#', ' ')).toEqual('5 600#40');
+});

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно