Bläddra i källkod

Merge branch 'main' into docs/cdn2

Josh Hanley 2 år sedan
förälder
incheckning
21e26ce8f8
65 ändrade filer med 6046 tillägg och 229 borttagningar
  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/persist/dist/cdn.js"></script>
     <script src="./packages/focus/dist/cdn.js"></script>
     <script src="./packages/focus/dist/cdn.js"></script>
     <script src="./packages/mask/dist/cdn.js"></script>
     <script src="./packages/mask/dist/cdn.js"></script>
+    <script src="./packages/ui/dist/cdn.js" defer></script>
     <script src="./packages/alpinejs/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> -->
     <!-- <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>
     </div>
 </html>
 </html>

+ 7 - 1
packages/alpinejs/package.json

@@ -1,7 +1,13 @@
 {
 {
     "name": "alpinejs",
     "name": "alpinejs",
-    "version": "3.10.2",
+    "version": "3.10.5",
     "description": "The rugged, minimal JavaScript framework",
     "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",
     "author": "Caleb Porzio",
     "license": "MIT",
     "license": "MIT",
     "main": "dist/module.cjs.js",
     "main": "dist/module.cjs.js",

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

@@ -1,8 +1,15 @@
+import { attributesOnly, directives } from "./directives"
 
 
 let binds = {}
 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) {
 export function injectBindingProviders(obj) {
@@ -18,3 +25,37 @@ export function injectBindingProviders(obj) {
 
 
     return obj
     return obj
 }
 }
+
+export function addVirtualBindings(el, bindings) {
+    let getBindings = typeof bindings !== 'function' ? () => bindings : bindings
+
+    el._x_virtualDirectives = getBindings()
+}
+
+export function applyBindingsObject(el, obj, original) {
+    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) {
 export function directives(el, attributes, originalAttributeOverride) {
+    attributes = Array.from(attributes)
+
+    if (el._x_virtualDirectives) {
+        let vAttributes = Object.entries(el._x_virtualDirectives).map(([name, value]) => ({ name, value }))
+
+        let staticAttributes = attributesOnly(vAttributes)
+
+        // Handle binding normal HTML attributes (non-Alpine directives).
+        vAttributes = vAttributes.map(attribute => {
+            if (staticAttributes.find(attr => attr.name === attribute.name)) {
+                return {
+                    name: `x-bind:${attribute.name}`,
+                    value: `"${attribute.value}"`,
+                }
+            }
+
+            return attribute
+        })
+
+        attributes = attributes.concat(vAttributes)
+    }
+
     let transformedAttributeMap = {}
     let transformedAttributeMap = {}
 
 
-    let directives = Array.from(attributes)
+    let directives = attributes
         .map(toTransformedAttributes((newName, oldName) => transformedAttributeMap[newName] = oldName))
         .map(toTransformedAttributes((newName, oldName) => transformedAttributeMap[newName] = oldName))
         .filter(outNonAlpineAttributes)
         .filter(outNonAlpineAttributes)
         .map(toParsedDirectives(transformedAttributeMap, originalAttributeOverride))
         .map(toParsedDirectives(transformedAttributeMap, originalAttributeOverride))
@@ -167,6 +189,16 @@ let directiveOrder = [
     'ref',
     'ref',
     'data',
     'data',
     'id',
     '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',
     'bind',
     'init',
     'init',
     'for',
     'for',
@@ -178,7 +210,6 @@ let directiveOrder = [
     'if',
     'if',
     DEFAULT,
     DEFAULT,
     'teleport',
     'teleport',
-    'element',
 ]
 ]
 
 
 function byPriority(a, b) {
 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 { evaluateLater } from '../evaluator'
 import { mutateDom } from '../mutation'
 import { mutateDom } from '../mutation'
 import bind from '../utils/bind'
 import bind from '../utils/bind'
-import { injectBindingProviders } from '../binds'
+import { applyBindingsObject, injectBindingProviders } from '../binds'
 
 
 mapAttributes(startingWith(':', into(prefix('bind:'))))
 mapAttributes(startingWith(':', into(prefix('bind:'))))
 
 
 directive('bind', (el, { value, modifiers, expression, original }, { effect }) => {
 directive('bind', (el, { value, modifiers, expression, original }, { effect }) => {
     if (! value) {
     if (! value) {
-        return applyBindingsObject(el, expression, original, effect)
+        let bindingProviders = {}
+        injectBindingProviders(bindingProviders)
+
+        let getBindings = evaluateLater(el, expression)
+
+        getBindings(bindings => {
+            applyBindingsObject(el, bindings, original)
+        }, { scope: bindingProviders } )
+
+        return
     }
     }
 
 
     if (value === 'key') return storeKeyForXFor(el, expression)
     if (value === 'key') return storeKeyForXFor(el, expression)
@@ -17,46 +26,14 @@ directive('bind', (el, { value, modifiers, expression, original }, { effect }) =
 
 
     effect(() => evaluate(result => {
     effect(() => evaluate(result => {
         // If nested object key is undefined, set the default value to empty string.
         // 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))
         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) {
 function storeKeyForXFor(el, expression) {
     el._x_keyExpression = expression
     el._x_keyExpression = expression

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

@@ -1,6 +1,7 @@
 import { evaluateLater } from '../evaluator'
 import { evaluateLater } from '../evaluator'
 import { directive } from '../directives'
 import { directive } from '../directives'
 import { mutateDom } from '../mutation'
 import { mutateDom } from '../mutation'
+import { nextTick } from '../nextTick'
 import bind from '../utils/bind'
 import bind from '../utils/bind'
 import on from '../utils/on'
 import on from '../utils/on'
 
 
@@ -34,6 +35,17 @@ directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
 
 
     cleanup(() => el._x_removeModelListeners['default']())
     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.
     // Allow programmatic overiding of x-model.
     let evaluateSetModel = evaluateLater(el, `${expression} = __placeholder`)
     let evaluateSetModel = evaluateLater(el, `${expression} = __placeholder`)
     el._x_model = {
     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({})
     if (! el._x_bindings) el._x_bindings = reactive({})
 
 
     el._x_bindings[name] = value
     el._x_bindings[name] = value
-   
+
     name = modifiers.includes('camel') ? camelCase(name) : name
     name = modifiers.includes('camel') ? camelCase(name) : name
 
 
     switch (name) {
     switch (name) {
@@ -129,21 +129,21 @@ function attributeShouldntBePreservedIfFalsy(name) {
 }
 }
 
 
 export function getBinding(el, name, fallback) {
 export function getBinding(el, name, fallback) {
-    // First let's get it out of Alpine bound data. 
+    // First let's get it out of Alpine bound data.
     if (el._x_bindings && el._x_bindings[name] !== undefined) return el._x_bindings[name]
     if (el._x_bindings && el._x_bindings[name] !== undefined) return el._x_bindings[name]
 
 
-    // If not, we'll return the literal attribute. 
+    // If not, we'll return the literal attribute.
     let attr = el.getAttribute(name)
     let attr = el.getAttribute(name)
 
 
     // Nothing bound:
     // Nothing bound:
     if (attr === null) return typeof fallback === 'function' ? fallback() : fallback
     if (attr === null) return typeof fallback === 'function' ? fallback() : fallback
-   
+
+    // The case of a custom attribute with no value. Ex: <div manual>
+    if (attr === '') return true
+
     if (isBooleanAttr(name)) {
     if (isBooleanAttr(name)) {
         return !! [name, 'true'].includes(attr)
         return !! [name, 'true'].includes(attr)
     }
     }
 
 
-    // The case of a custom attribute with no value. Ex: <div manual> 
-    if (attr === '') return true
-   
     return attr
     return attr
 }
 }

+ 7 - 1
packages/collapse/package.json

@@ -1,7 +1,13 @@
 {
 {
     "name": "@alpinejs/collapse",
     "name": "@alpinejs/collapse",
-    "version": "3.10.2",
+    "version": "3.10.5",
     "description": "Collapse and expand elements with robust animations",
     "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",
     "author": "Caleb Porzio",
     "license": "MIT",
     "license": "MIT",
     "main": "dist/module.cjs.js",
     "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
         // We use the hidden attribute for the benefit of Tailwind
         // users as the .space utility will ignore [hidden] elements.
         // users as the .space utility will ignore [hidden] elements.
         // We also use display:none as the hidden attribute has very
         // 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.
         // by a user.
         if (! el._x_isShown && fullyHide) el.hidden = true
         if (! el._x_isShown && fullyHide) el.hidden = true
         if (! el._x_isShown) el.style.overflow = 'hidden'
         if (! el._x_isShown) el.style.overflow = 'hidden'
@@ -56,7 +56,7 @@ export default function (Alpine) {
                     start: { height: current+'px' },
                     start: { height: current+'px' },
                     end: { height: full+'px' },
                     end: { height: full+'px' },
                 }, () => el._x_isShown = true, () => {
                 }, () => el._x_isShown = true, () => {
-                    if (el.style.height == `${full}px`) {
+                    if (el.getBoundingClientRect().height == full) {
                         el.style.overflow = null
                         el.style.overflow = null
                     }
                     }
                 })
                 })

+ 1 - 1
packages/docs/package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "@alpinejs/docs",
     "name": "@alpinejs/docs",
-    "version": "3.10.2-revision.2",
+    "version": "3.10.5-revision.1",
     "description": "The documentation for Alpine",
     "description": "The documentation for Alpine",
     "author": "Caleb Porzio",
     "author": "Caleb Porzio",
     "license": "MIT"
     "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`:
 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>
 <a name="keys"></a>
 ## Keys
 ## 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.
 `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                   |
 | `.caps-lock`                | Caps Lock                   |
 | `.equal`                    | Equal, `=`                  |
 | `.equal`                    | Equal, `=`                  |
 | `.period`                   | Period, `.`                 |
 | `.period`                   | Period, `.`                 |
-| `.slash`                    | Foward Slash, `/`           |
+| `.slash`                    | Forward Slash, `/`           |
 
 
 <a name="custom-events"></a>
 <a name="custom-events"></a>
 ## Custom events
 ## 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.
 Notice the `@3.x.x` in the provided CDN link. This will pull the latest version of Alpine version 3. For stability in production, it's recommended that you hardcode the latest version in the CDN link.
 
 
 ```alpine
 ```alpine
-<script defer src="https://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.
 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.
 This is useful for many different types of inputs: phone numbers, credit cards, dollar amounts, account numbers, dates, etc.
 
 
 <a name="installation"></a>
 <a name="installation"></a>
+
 ## Installation
 ## Installation
 
 
 <div x-data="{ expanded: false }">
 <div x-data="{ expanded: false }">
@@ -60,6 +61,7 @@ Alpine.plugin(mask)
  </div>
  </div>
 
 
 <a name="x-mask"></a>
 <a name="x-mask"></a>
+
 ## x-mask
 ## x-mask
 
 
 The primary API for using this plugin is the `x-mask` directive.
 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:
 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>
 <a name="mask-functions"></a>
+
 ## Dynamic Masks
 ## 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.
 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>
 </div>
 <!-- END_VERBATIM -->
 <!-- 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
 ```alpine
 <input x-mask:dynamic="creditCardMask">
 <input x-mask:dynamic="creditCardMask">
@@ -128,6 +131,7 @@ function creditCardMask(input) {
 ```
 ```
 
 
 <a name="money-inputs"></a>
 <a name="money-inputs"></a>
+
 ## Money Inputs
 ## 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()`.
 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">
     <input type="text" x-mask:dynamic="$money($input, ',')"  placeholder="0,00">
 </div>
 </div>
 <!-- END_VERBATIM -->
 <!-- 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",
     "name": "@alpinejs/focus",
-    "version": "3.10.2",
+    "version": "3.10.5",
     "description": "Manage focus within a page",
     "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",
     "author": "Caleb Porzio",
     "license": "MIT",
     "license": "MIT",
     "main": "dist/module.cjs.js",
     "main": "dist/module.cjs.js",

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

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

+ 6 - 0
packages/history/package.json

@@ -2,6 +2,12 @@
     "name": "@alpinejs/history",
     "name": "@alpinejs/history",
     "version": "3.0.0-alpha.0",
     "version": "3.0.0-alpha.0",
     "description": "Sync Alpine data with the browser's query string",
     "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",
     "author": "Caleb Porzio",
     "license": "MIT",
     "license": "MIT",
     "main": "dist/module.cjs.js",
     "main": "dist/module.cjs.js",

+ 7 - 1
packages/intersect/package.json

@@ -1,7 +1,13 @@
 {
 {
     "name": "@alpinejs/intersect",
     "name": "@alpinejs/intersect",
-    "version": "3.10.2",
+    "version": "3.10.5",
     "description": "Trigger JavaScript when an element enters the viewport",
     "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",
     "author": "Caleb Porzio",
     "license": "MIT",
     "license": "MIT",
     "main": "dist/module.cjs.js",
     "main": "dist/module.cjs.js",

+ 7 - 1
packages/mask/package.json

@@ -1,7 +1,13 @@
 {
 {
     "name": "@alpinejs/mask",
     "name": "@alpinejs/mask",
-    "version": "3.10.2",
+    "version": "3.10.5",
     "description": "An Alpine plugin for input masking",
     "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",
     "author": "Caleb Porzio",
     "license": "MIT",
     "license": "MIT",
     "main": "dist/module.cjs.js",
     "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:
                 // Run on initialize which serves a dual purpose:
                 // - Initializing the mask on the input if it has an initial value.
                 // - Initializing the mask on the input if it has an initial value.
                 // - Running the template function to set up reactivity, so that
                 // - 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)
                 processInputValue(el)
             })
             })
         } else {
         } else {
@@ -163,9 +163,8 @@ export function buildUp(template, input) {
     return output
     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 addThousands = (input, thousands) => {
         let output = ''
         let output = ''
@@ -186,17 +185,17 @@ function formatMoney(input, delimeter = '.', thousands) {
         return output
         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)
     template = addThousands(template, thousands)
 
 
-    if (input.includes(delimeter)) template += `${delimeter}99`
+    if (input.includes(delimiter)) template += `${delimiter}99`
 
 
     queueMicrotask(() => {
     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)
             this.el.setSelectionRange(this.el.selectionStart - 1, this.el.selectionStart - 1)
         }
         }
     })
     })

+ 7 - 1
packages/morph/package.json

@@ -1,7 +1,13 @@
 {
 {
     "name": "@alpinejs/morph",
     "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",
     "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",
     "author": "Caleb Porzio",
     "license": "MIT",
     "license": "MIT",
     "main": "dist/module.cjs.js",
     "main": "dist/module.cjs.js",

+ 7 - 1
packages/persist/package.json

@@ -1,7 +1,13 @@
 {
 {
     "name": "@alpinejs/persist",
     "name": "@alpinejs/persist",
-    "version": "3.10.2",
+    "version": "3.10.5",
     "description": "Persist Alpine data across page loads",
     "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",
     "author": "Caleb Porzio",
     "license": "MIT",
     "license": "MIT",
     "main": "dist/module.cjs.js",
     "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

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 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',
     'morph',
     'focus',
     'focus',
     'mask',
     'mask',
+    'ui',
 ]).forEach(package => {
 ]).forEach(package => {
     if (! fs.existsSync(`./packages/${package}/dist`)) {
     if (! fs.existsSync(`./packages/${package}/dist`)) {
         fs.mkdirSync(`./packages/${package}/dist`, 0744);
         fs.mkdirSync(`./packages/${package}/dist`, 0744);

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

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

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

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

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

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

+ 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',
 test('$money works with permenant inserted at beginning',
     [html`<input x-data x-mask:dynamic="$money">`],
     [html`<input x-data x-mask:dynamic="$money">`],
     ({ get }) => {
     ({ 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',
 test('can persist number',
     [html`
     [html`
@@ -83,11 +83,11 @@ test('can persist boolean',
         </div>
         </div>
     `],
     `],
     ({ get }, reload) => {
     ({ get }, reload) => {
-        get('span').should(notBeVisible())
+        get('span').should(notExist())
         get('button').click()
         get('button').click()
-        get('span').should(beVisible())
+        get('span').should(exist())
         reload()
         reload()
-        get('span').should(beVisible())
+        get('span').should(exist())
     },
     },
 )
 )
 
 
@@ -128,14 +128,14 @@ test('can persist using an alias',
         </div>
         </div>
     `],
     `],
     ({ get }, reload) => {
     ({ get }, reload) => {
-        get('span#one').should(notBeVisible())
-        get('span#two').should(notBeVisible())
+        get('span#one').should(notExist())
+        get('span#two').should(notExist())
         get('button').click()
         get('button').click()
-        get('span#one').should(notBeVisible())
-        get('span#two').should(beVisible())
+        get('span#one').should(notExist())
+        get('span#two').should(exist())
         reload()
         reload()
-        get('span#one').should(notBeVisible())
-        get('span#two').should(beVisible())
+        get('span#one').should(notExist())
+        get('span#two').should(exist())
     },
     },
 )
 )
 
 
@@ -155,14 +155,14 @@ test('aliases do not affect other $persist calls',
         </div>
         </div>
     `],
     `],
     ({ get }, reload) => {
     ({ get }, reload) => {
-        get('span#one').should(notBeVisible())
-        get('span#two').should(notBeVisible())
+        get('span#one').should(notExist())
+        get('span#two').should(notExist())
         get('button').click()
         get('button').click()
-        get('span#one').should(notBeVisible())
-        get('span#two').should(beVisible())
+        get('span#one').should(notExist())
+        get('span#two').should(exist())
         reload()
         reload()
-        get('span#one').should(notBeVisible())
-        get('span#two').should(beVisible())
+        get('span#one').should(notExist())
+        get('span#two').should(exist())
     },
     },
 )
 )
 
 

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

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

+ 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/intersect/dist/cdn.js"></script>
     <script src="/../../packages/collapse/dist/cdn.js"></script>
     <script src="/../../packages/collapse/dist/cdn.js"></script>
     <script src="/../../packages/mask/dist/cdn.js"></script>
     <script src="/../../packages/mask/dist/cdn.js"></script>
+    <script src="/../../packages/ui/dist/cdn.js"></script>
     <script>
     <script>
         let root = document.querySelector('#root')
         let root = document.querySelector('#root')
 
 

+ 11 - 1
tests/cypress/utils.js

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

+ 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 () => {
 test('strip-down functionality', async () => {
     expect(stripDown('(***) ***-****', '7162256108')).toEqual('7162256108')
     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) 2256108')).toEqual('7162256108')
     expect(stripDown('(999) 999-9999', '(716) 2-25--6108')).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');
+});

Vissa filer visades inte eftersom för många filer har ändrats