浏览代码

Merge branch 'main' into livewire-v3

Caleb Porzio 2 年之前
父节点
当前提交
a2000ff6ce
共有 63 个文件被更改,包括 4636 次插入110 次删除
  1. 163 12
      index.html
  2. 1 1
      morph.html
  3. 7 1
      packages/alpinejs/package.json
  4. 10 0
      packages/alpinejs/src/directives.js
  5. 3 1
      packages/alpinejs/src/directives/x-bind.js
  6. 12 0
      packages/alpinejs/src/directives/x-model.js
  7. 11 6
      packages/alpinejs/src/directives/x-teleport.js
  8. 1 1
      packages/alpinejs/src/evaluator.js
  9. 0 1
      packages/alpinejs/src/lifecycle.js
  10. 3 3
      packages/alpinejs/src/utils/bind.js
  11. 11 2
      packages/alpinejs/src/utils/on.js
  12. 7 1
      packages/collapse/package.json
  13. 2 2
      packages/collapse/src/index.js
  14. 1 1
      packages/docs/package.json
  15. 9 1
      packages/docs/src/en/directives/cloak.md
  16. 24 2
      packages/docs/src/en/directives/for.md
  17. 1 1
      packages/docs/src/en/directives/on.md
  18. 2 2
      packages/docs/src/en/essentials/installation.md
  19. 2 2
      packages/docs/src/en/plugins/collapse.md
  20. 3 3
      packages/docs/src/en/plugins/focus.md
  21. 3 3
      packages/docs/src/en/plugins/intersect.md
  22. 24 8
      packages/docs/src/en/plugins/mask.md
  23. 3 3
      packages/docs/src/en/plugins/morph.md
  24. 3 3
      packages/docs/src/en/plugins/persist.md
  25. 1 1
      packages/docs/src/en/start-here.md
  26. 7 1
      packages/focus/package.json
  27. 6 0
      packages/history/package.json
  28. 7 1
      packages/intersect/package.json
  29. 7 1
      packages/mask/package.json
  30. 10 9
      packages/mask/src/index.js
  31. 7 1
      packages/morph/package.json
  32. 7 1
      packages/persist/package.json
  33. 18 0
      packages/ui/demo/index.html
  34. 105 0
      packages/ui/demo/listbox/data-driven.html
  35. 233 0
      packages/ui/demo/listbox/index.html
  36. 105 0
      packages/ui/demo/listbox/multiple.html
  37. 7 1
      packages/ui/package.json
  38. 4 0
      packages/ui/src/dialog.js
  39. 80 0
      packages/ui/src/disclosure.js
  40. 14 0
      packages/ui/src/index.js
  41. 460 0
      packages/ui/src/list-context.js
  42. 244 0
      packages/ui/src/listbox.js
  43. 229 0
      packages/ui/src/menu.js
  44. 26 8
      packages/ui/src/popover.js
  45. 220 0
      packages/ui/src/radio.js
  46. 116 0
      packages/ui/src/switch.js
  47. 13 8
      packages/ui/src/tabs.js
  48. 1 1
      scripts/build.js
  49. 6 4
      tests/cypress/integration/directives/x-bind.spec.js
  50. 19 0
      tests/cypress/integration/directives/x-model.spec.js
  51. 55 0
      tests/cypress/integration/directives/x-on.spec.js
  52. 38 0
      tests/cypress/integration/directives/x-teleport.spec.js
  53. 25 0
      tests/cypress/integration/plugins/mask.spec.js
  54. 102 0
      tests/cypress/integration/plugins/ui/disclosure.spec.js
  55. 868 0
      tests/cypress/integration/plugins/ui/listbox.spec.js
  56. 392 0
      tests/cypress/integration/plugins/ui/menu.spec.js
  57. 56 6
      tests/cypress/integration/plugins/ui/popover.spec.js
  58. 513 0
      tests/cypress/integration/plugins/ui/radio.spec.js
  59. 151 0
      tests/cypress/integration/plugins/ui/switch.spec.js
  60. 144 4
      tests/cypress/integration/plugins/ui/tabs.spec.js
  61. 1 1
      tests/cypress/manual-memory.html
  62. 1 1
      tests/cypress/manual-transition-test.html
  63. 32 1
      tests/jest/mask.spec.js

+ 163 - 12
index.html

@@ -5,23 +5,174 @@
     <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"></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="//cdn.tailwindcss.com"></script>
+    <!-- <script src="https://cdn.jsdelivr.net/npm/alpinejs@3.0.0/dist/cdn.min.js" defer></script> -->
 
 
-    <!-- if -->    |  <!-- if -->
-    foo            |  yo
-    bar            |  <!-- end-->
-    <!-- end -->   |
-                   |  baz
-    baz
+    <!-- <div x-data="{ value: null }">
+        Value: <span x-text="value"></span>
 
 
-    <div>
-        <!-- if -->
-        <div>foo<input></div>
-        <!-- end -->
+        <button @click="value = 'bar'">Change value</button>
 
 
-        <div>bar<input></div>
+        <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>
 
 
     <main x-data x-ignore style="display: none">
     <main x-data x-ignore style="display: none">

+ 1 - 1
morph.html

@@ -1,7 +1,7 @@
 <html>
 <html>
     <script src="./packages/morph/dist/cdn.js" defer></script>
     <script src="./packages/morph/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="https://unpkg.com/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> -->
 
 
 <div id="before">
 <div id="before">
 <!-- Before markup goes here: -->
 <!-- Before markup goes here: -->

+ 7 - 1
packages/alpinejs/package.json

@@ -1,7 +1,13 @@
 {
 {
     "name": "alpinejs",
     "name": "alpinejs",
-    "version": "3.10.3",
+    "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",

+ 10 - 0
packages/alpinejs/src/directives.js

@@ -189,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',

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

@@ -26,7 +26,9 @@ 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))
     }))
     }))

+ 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'
 import { warn } from '../utils/warn'
 import { warn } from '../utils/warn'
@@ -74,6 +75,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.
     el._x_model = {
     el._x_model = {
         get() {
         get() {

+ 11 - 6
packages/alpinejs/src/directives/x-teleport.js

@@ -7,11 +7,7 @@ import { warn } from "../utils/warn"
 
 
 let teleportContainerDuringClone = document.createElement('div')
 let teleportContainerDuringClone = document.createElement('div')
 
 
-// export function getTeleportContainerDuringClone() {
-//     return teleportContainerDuringClone
-// }
-
-directive('teleport', (el, { expression }, { cleanup }) => {
+directive('teleport', (el, { modifiers, expression }, { cleanup }) => {
     if (el.tagName.toLowerCase() !== 'template') warn('x-teleport can only be used on a <template> tag', el)
     if (el.tagName.toLowerCase() !== 'template') warn('x-teleport can only be used on a <template> tag', el)
 
 
     let target = skipDuringClone(() => {
     let target = skipDuringClone(() => {
@@ -42,7 +38,16 @@ directive('teleport', (el, { expression }, { cleanup }) => {
     addScopeToNode(clone, {}, el)
     addScopeToNode(clone, {}, el)
 
 
     mutateDom(() => {
     mutateDom(() => {
-        target.appendChild(clone)
+        if (modifiers.includes('prepend')) {
+            // insert element before the target
+            target.parentNode.insertBefore(clone, target)
+        } else if (modifiers.includes('append')) {
+            // insert element after the target
+            target.parentNode.insertBefore(clone, target.nextSibling)
+        } else {
+            // origin
+            target.appendChild(clone)
+        }
 
 
         initTree(clone)
         initTree(clone)
 
 

+ 1 - 1
packages/alpinejs/src/evaluator.js

@@ -73,7 +73,7 @@ function generateFunctionFromString(expression, el) {
         || /^[\n\s]*if.*\(.*\)/.test(expression)
         || /^[\n\s]*if.*\(.*\)/.test(expression)
         // Support expressions starting with "let/const" like: "let foo = 'bar'"
         // Support expressions starting with "let/const" like: "let foo = 'bar'"
         || /^(let|const)\s/.test(expression)
         || /^(let|const)\s/.test(expression)
-            ? `(() => { ${expression} })()`
+            ? `(async()=>{ ${expression} })()`
             : expression
             : expression
 
 
     const safeAsyncFunction = () => {
     const safeAsyncFunction = () => {

+ 0 - 1
packages/alpinejs/src/lifecycle.js

@@ -1,7 +1,6 @@
 import { startObservingMutations, onAttributesAdded, onElAdded, onElRemoved, cleanupAttributes } from "./mutation"
 import { startObservingMutations, onAttributesAdded, onElAdded, onElRemoved, cleanupAttributes } from "./mutation"
 import { deferHandlingDirectives, directives } from "./directives"
 import { deferHandlingDirectives, directives } from "./directives"
 import { dispatch } from './utils/dispatch'
 import { dispatch } from './utils/dispatch'
-import { nextTick } from "./nextTick"
 import { walk } from "./utils/walk"
 import { walk } from "./utils/walk"
 import { warn } from './utils/warn'
 import { warn } from './utils/warn'
 
 

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

@@ -138,12 +138,12 @@ export function getBinding(el, name, fallback) {
     // 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
 }
 }

+ 11 - 2
packages/alpinejs/src/utils/on.js

@@ -93,6 +93,8 @@ function isNumeric(subject){
 }
 }
 
 
 function kebabCase(subject) {
 function kebabCase(subject) {
+    if ([' ','_'].includes(subject
+    )) return subject
     return subject.replace(/([a-z])([A-Z])/g, '$1-$2').replace(/[_\s]/, '-').toLowerCase()
     return subject.replace(/([a-z])([A-Z])/g, '$1-$2').replace(/[_\s]/, '-').toLowerCase()
 }
 }
 
 
@@ -110,6 +112,11 @@ function isListeningForASpecificKeyThatHasntBeenPressed(e, modifiers) {
         keyModifiers.splice(debounceIndex, isNumeric((keyModifiers[debounceIndex+1] || 'invalid-wait').split('ms')[0]) ? 2 : 1)
         keyModifiers.splice(debounceIndex, isNumeric((keyModifiers[debounceIndex+1] || 'invalid-wait').split('ms')[0]) ? 2 : 1)
     }
     }
 
 
+    if (keyModifiers.includes('throttle')) {
+        let debounceIndex = keyModifiers.indexOf('throttle')
+        keyModifiers.splice(debounceIndex, isNumeric((keyModifiers[debounceIndex+1] || 'invalid-wait').split('ms')[0]) ? 2 : 1)
+    }
+
     // If no modifier is specified, we'll call it a press.
     // If no modifier is specified, we'll call it a press.
     if (keyModifiers.length === 0) return false
     if (keyModifiers.length === 0) return false
 
 
@@ -149,8 +156,8 @@ function keyToModifiers(key) {
     let modifierToKeyMap = {
     let modifierToKeyMap = {
         'ctrl': 'control',
         'ctrl': 'control',
         'slash': '/',
         'slash': '/',
-        'space': '-',
-        'spacebar': '-',
+        'space': ' ',
+        'spacebar': ' ',
         'cmd': 'meta',
         'cmd': 'meta',
         'esc': 'escape',
         'esc': 'escape',
         'up': 'arrow-up',
         'up': 'arrow-up',
@@ -159,6 +166,8 @@ function keyToModifiers(key) {
         'right': 'arrow-right',
         'right': 'arrow-right',
         'period': '.',
         'period': '.',
         'equal': '=',
         'equal': '=',
+        'minus': '-',
+        'underscore': '_',
     }
     }
 
 
     modifierToKeyMap[key] = key
     modifierToKeyMap[key] = key

+ 7 - 1
packages/collapse/package.json

@@ -1,7 +1,13 @@
 {
 {
     "name": "@alpinejs/collapse",
     "name": "@alpinejs/collapse",
-    "version": "3.10.3",
+    "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.3-revision.1",
+    "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"

+ 9 - 1
packages/docs/src/en/directives/cloak.md

@@ -15,7 +15,13 @@ For `x-cloak` to work however, you must add the following CSS to the page.
 [x-cloak] { display: none !important; }
 [x-cloak] { display: none !important; }
 ```
 ```
 
 
-Now, the following example will hide the `<span>` tag until Alpine has set its text content to the `message` property.
+The following example will hide the `<span>` tag until its `x-show` is specifically set to true, preventing any "blip" of the hidden element onto screen as Alpine loads.
+
+```alpine
+<span x-cloak x-show="false">This will not 'blip' onto screen at any point</span>
+```
+
+`x-cloak` doesn't just work on elements hidden by `x-show` or `x-if`: it also ensures that elements containing data are hidden until the data is correctly set. The following example will hide the `<span>` tag until Alpine has set its text content to the `message` property.
 
 
 ```alpine
 ```alpine
 <span x-cloak x-text="message"></span>
 <span x-cloak x-text="message"></span>
@@ -23,6 +29,8 @@ Now, the following example will hide the `<span>` tag until Alpine has set its t
 
 
 When Alpine loads on the page, it removes all `x-cloak` property from the element, which also removes the `display: none;` applied by CSS, therefore showing the element.
 When Alpine loads on the page, it removes all `x-cloak` property from the element, which also removes the `display: none;` applied by CSS, therefore showing the element.
 
 
+## Alternative to global syntax
+
 If you'd like to achieve this same behavior, but avoid having to include a global style, you can use the following cool, but admittedly odd trick:
 If you'd like to achieve this same behavior, but avoid having to include a global style, you can use the following cool, but admittedly odd trick:
 
 
 ```alpine
 ```alpine

+ 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

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

@@ -22,7 +22,7 @@ This is by far the simplest way to get started with Alpine. Include the followin
   <head>
   <head>
     ...
     ...
 
 
-    <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
+    <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
   </head>
   </head>
   ...
   ...
 </html>
 </html>
@@ -33,7 +33,7 @@ This is by far the simplest way to get started with Alpine. Include the followin
 Notice the `@3.x.x` in the provided CDN link. This will pull the latest version of Alpine version 3. For stability in production, it's recommended that you hardcode the latest version in the CDN link.
 Notice the `@3.x.x` in the provided CDN link. This will pull the latest version of Alpine version 3. For stability in production, it's recommended that you hardcode the latest version in the CDN link.
 
 
 ```alpine
 ```alpine
-<script defer src="https://unpkg.com/alpinejs@3.10.3/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.

+ 2 - 2
packages/docs/src/en/plugins/collapse.md

@@ -22,10 +22,10 @@ You can include the CDN build of this plugin as a `<script>` tag, just make sure
 
 
 ```alpine
 ```alpine
 <!-- Alpine Plugins -->
 <!-- Alpine Plugins -->
-<script defer src="https://unpkg.com/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
+<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
 
 
 <!-- Alpine Core -->
 <!-- Alpine Core -->
-<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
+<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
 ```
 ```
 
 
 ### Via NPM
 ### Via NPM

+ 3 - 3
packages/docs/src/en/plugins/focus.md

@@ -24,10 +24,10 @@ You can include the CDN build of this plugin as a `<script>` tag, just make sure
 
 
 ```alpine
 ```alpine
 <!-- Alpine Plugins -->
 <!-- Alpine Plugins -->
-<script defer src="https://unpkg.com/@alpinejs/focus@3.x.x/dist/cdn.min.js"></script>
+<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/focus@3.x.x/dist/cdn.min.js"></script>
 
 
 <!-- Alpine Core -->
 <!-- Alpine Core -->
-<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
+<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
 ```
 ```
 
 
 ### Via NPM
 ### Via NPM
@@ -309,7 +309,7 @@ This plugin offers many smaller utilities for managing focus within a page. Thes
 | Property | Description |
 | Property | Description |
 | ---       | --- |
 | ---       | --- |
 | `focus(el)`   | Focus the passed element (handling annoyances internally: using nextTick, etc.) |
 | `focus(el)`   | Focus the passed element (handling annoyances internally: using nextTick, etc.) |
-| `focusable(el)`   | Detect weather or not an element is focusable |
+| `focusable(el)`   | Detect whether or not an element is focusable |
 | `focusables()`   | Get all "focusable" elements within the current element |
 | `focusables()`   | Get all "focusable" elements within the current element |
 | `focused()`   | Get the currently focused element on the page |
 | `focused()`   | Get the currently focused element on the page |
 | `lastFocused()`   | Get the last focused element on the page |
 | `lastFocused()`   | Get the last focused element on the page |

+ 3 - 3
packages/docs/src/en/plugins/intersect.md

@@ -22,10 +22,10 @@ You can include the CDN build of this plugin as a `<script>` tag, just make sure
 
 
 ```alpine
 ```alpine
 <!-- Alpine Plugins -->
 <!-- Alpine Plugins -->
-<script defer src="https://unpkg.com/@alpinejs/intersect@3.x.x/dist/cdn.min.js"></script>
+<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/intersect@3.x.x/dist/cdn.min.js"></script>
 
 
 <!-- Alpine Core -->
 <!-- Alpine Core -->
-<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
+<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
 ```
 ```
 
 
 ### Via NPM
 ### Via NPM
@@ -152,7 +152,7 @@ If you wanted to trigger only when 5% of the element has entered the viewport, y
 Allows you to control the `rootMargin` property of the underlying `IntersectionObserver`.
 Allows you to control the `rootMargin` property of the underlying `IntersectionObserver`.
 This effectively tweaks the size of the viewport boundary. Positive values
 This effectively tweaks the size of the viewport boundary. Positive values
 expand the boundary beyond the viewport, and negative values shrink it inward. The values
 expand the boundary beyond the viewport, and negative values shrink it inward. The values
-work like CSS margin: one value for all sides, two values for top/bottom, left/right, or
+work like CSS margin: one value for all sides; two values for top/bottom, left/right; or
 four values for top, right, bottom, left. You can use `px` and `%` values, or use a bare number to
 four values for top, right, bottom, left. You can use `px` and `%` values, or use a bare number to
 get a pixel value.
 get a pixel value.
 
 

+ 24 - 8
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 }">
@@ -27,10 +28,10 @@ You can include the CDN build of this plugin as a `<script>` tag, just make sure
 
 
 ```alpine
 ```alpine
 <!-- Alpine Plugins -->
 <!-- Alpine Plugins -->
-<script defer src="https://unpkg.com/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>
+<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>
 
 
 <!-- Alpine Core -->
 <!-- Alpine Core -->
-<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
+<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
 ```
 ```
 
 
 ### Via NPM
 ### Via NPM
@@ -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 -->

+ 3 - 3
packages/docs/src/en/plugins/morph.md

@@ -9,7 +9,7 @@ graph_image: https://alpinejs.dev/social_morph.jpg
 
 
 Alpine's Morph plugin allows you to "morph" an element on the page into the provided HTML template, all while preserving any browser or Alpine state within the "morphed" element.
 Alpine's Morph plugin allows you to "morph" an element on the page into the provided HTML template, all while preserving any browser or Alpine state within the "morphed" element.
 
 
-This is useful for updating HTML from a server request without loosing Alpine's on-page state. A utility like this is at the core of full-stack frameworks like [Laravel Livewire](https://laravel-livewire.com/) and [Phoenix LiveView](https://dockyard.com/blog/2018/12/12/phoenix-liveview-interactive-real-time-apps-no-need-to-write-javascript).
+This is useful for updating HTML from a server request without losing Alpine's on-page state. A utility like this is at the core of full-stack frameworks like [Laravel Livewire](https://laravel-livewire.com/) and [Phoenix LiveView](https://dockyard.com/blog/2018/12/12/phoenix-liveview-interactive-real-time-apps-no-need-to-write-javascript).
 
 
 The best way to understand its purpose is with the following interactive visualization. Give it a try!
 The best way to understand its purpose is with the following interactive visualization. Give it a try!
 
 
@@ -41,10 +41,10 @@ You can include the CDN build of this plugin as a `<script>` tag, just make sure
 
 
 ```alpine
 ```alpine
 <!-- Alpine Plugins -->
 <!-- Alpine Plugins -->
-<script defer src="https://unpkg.com/@alpinejs/morph@3.x.x/dist/cdn.min.js"></script>
+<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/morph@3.x.x/dist/cdn.min.js"></script>
 
 
 <!-- Alpine Core -->
 <!-- Alpine Core -->
-<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
+<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
 ```
 ```
 
 
 ### Via NPM
 ### Via NPM

+ 3 - 3
packages/docs/src/en/plugins/persist.md

@@ -22,10 +22,10 @@ You can include the CDN build of this plugin as a `<script>` tag, just make sure
 
 
 ```alpine
 ```alpine
 <!-- Alpine Plugins -->
 <!-- Alpine Plugins -->
-<script defer src="https://unpkg.com/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
+<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
 
 
 <!-- Alpine Core -->
 <!-- Alpine Core -->
-<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
+<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
 ```
 ```
 
 
 ### Via NPM
 ### Via NPM
@@ -204,4 +204,4 @@ Alpine.data('dropdown', function () {
 Alpine.store('darkMode', {
 Alpine.store('darkMode', {
     on: Alpine.$persist(true).as('darkMode_on')
     on: Alpine.$persist(true).as('darkMode_on')
 });
 });
-```
+```

+ 1 - 1
packages/docs/src/en/start-here.md

@@ -12,7 +12,7 @@ Using a text editor, fill the file with these contents:
 ```alpine
 ```alpine
 <html>
 <html>
 <head>
 <head>
-    <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
+    <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
 </head>
 </head>
 <body>
 <body>
     <h1 x-data="{ message: 'I ❤️ Alpine' }" x-text="message"></h1>
     <h1 x-data="{ message: 'I ❤️ Alpine' }" x-text="message"></h1>

+ 7 - 1
packages/focus/package.json

@@ -1,7 +1,13 @@
 {
 {
     "name": "@alpinejs/focus",
     "name": "@alpinejs/focus",
-    "version": "3.10.3",
+    "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",

+ 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.3",
+    "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.3",
+    "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",

+ 10 - 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,10 @@ export function buildUp(template, input) {
     return output
     return output
 }
 }
 
 
-function formatMoney(input, delimeter = '.', thousands) {
-    thousands = (delimeter === ',' && thousands === undefined)
-        ? '.' : ','
+export function formatMoney(input, delimiter = '.', thousands) {
+    if (/^\D+$/.test(input)) return '9'
+
+    thousands = thousands ?? (delimiter === "," ? "." : ",")
 
 
     let addThousands = (input, thousands) => {
     let addThousands = (input, thousands) => {
         let output = ''
         let output = ''
@@ -186,17 +187,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.3",
+    "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.3",
+    "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",

文件差异内容过多而无法显示
+ 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>
+
+
+

+ 7 - 1
packages/ui/package.json

@@ -1,7 +1,13 @@
 {
 {
     "name": "@alpinejs/ui",
     "name": "@alpinejs/ui",
-    "version": "3.10.3-beta.0",
+    "version": "3.10.5-beta.8",
     "description": "Headless UI components for Alpine",
     "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",
     "author": "Caleb Porzio",
     "license": "MIT",
     "license": "MIT",
     "main": "dist/module.cjs.js",
     "main": "dist/module.cjs.js",

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

@@ -12,9 +12,13 @@ export default function (Alpine) {
         let $data = Alpine.$data(el)
         let $data = Alpine.$data(el)
 
 
         return {
         return {
+            // Kept here for legacy. Remove after out of beta.
             get open() {
             get open() {
                 return $data.__isOpen
                 return $data.__isOpen
             },
             },
+            get isOpen() {
+                return $data.__isOpen
+            },
             close() {
             close() {
                 $data.__close()
                 $data.__close()
             }
             }

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

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

@@ -1,5 +1,19 @@
 import dialog from './dialog'
 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) {
 export default function (Alpine) {
     dialog(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)
+    },
+}

+ 26 - 8
packages/ui/src/popover.js

@@ -12,9 +12,15 @@ export default function (Alpine) {
         let $data = Alpine.$data(el)
         let $data = Alpine.$data(el)
 
 
         return {
         return {
-            get open() {
-                return $data.__isOpen
-            }
+            get isOpen() {
+                return $data.__isOpenState
+            },
+            open() {
+                $data.__open()
+            },
+            close() {
+                $data.__close()
+            },
         }
         }
     })
     })
 }
 }
@@ -22,6 +28,7 @@ export default function (Alpine) {
 function handleRoot(el, Alpine) {
 function handleRoot(el, Alpine) {
     Alpine.bind(el, {
     Alpine.bind(el, {
         'x-id'() { return ['alpine-popover-button', 'alpine-popover-panel'] },
         'x-id'() { return ['alpine-popover-button', 'alpine-popover-panel'] },
+        'x-modelable': '__isOpenState',
         'x-data'() {
         'x-data'() {
             return {
             return {
                 init() {
                 init() {
@@ -35,17 +42,25 @@ function handleRoot(el, Alpine) {
                 },
                 },
                 __buttonEl: undefined,
                 __buttonEl: undefined,
                 __panelEl: undefined,
                 __panelEl: undefined,
-                __isOpen: false,
+                __isStatic: false,
+                get __isOpen() {
+                    if (this.__isStatic) return true
+
+                    return this.__isOpenState
+                },
+                __isOpenState: false,
                 __open() {
                 __open() {
-                    this.__isOpen = true
+                    this.__isOpenState = true
 
 
                     this.$dispatch('__close-others', { el: this.$el })
                     this.$dispatch('__close-others', { el: this.$el })
                 },
                 },
                 __toggle() {
                 __toggle() {
-                    this.__isOpen ? this.__close() : this.__open()
+                    this.__isOpenState ? this.__close() : this.__open()
                 },
                 },
                 __close(el) {
                 __close(el) {
-                    this.__isOpen = false
+                    if (this.__isStatic) return
+
+                    this.__isOpenState = false
 
 
                     if (el === false) return
                     if (el === false) return
 
 
@@ -132,7 +147,10 @@ function handleButton(el, Alpine) {
 
 
 function handlePanel(el, Alpine) {
 function handlePanel(el, Alpine) {
     Alpine.bind(el, {
     Alpine.bind(el, {
-        'x-init'() { this.$data.__panelEl = this.$el },
+        'x-init'() {
+            this.$data.__isStatic = Alpine.bound(this.$el, 'static', false)
+            this.$data.__panelEl = this.$el
+        },
         'x-effect'() {
         'x-effect'() {
             this.$data.__isOpen && Alpine.bound(el, 'focus') && this.$focus.first()
             this.$data.__isOpen && Alpine.bound(el, 'focus') && this.$focus.first()
         },
         },

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

+ 13 - 8
packages/ui/src/tabs.js

@@ -12,17 +12,20 @@ export default function (Alpine) {
         let $data = Alpine.$data(el)
         let $data = Alpine.$data(el)
 
 
         return {
         return {
-            get selected() {
+            get isSelected() {
                 return $data.__selectedIndex === $data.__tabs.indexOf($data.__tabEl)
                 return $data.__selectedIndex === $data.__tabs.indexOf($data.__tabEl)
+            },
+            get isDisabled() {
+                return $data.__isDisabled
             }
             }
         }
         }
     })
     })
 
 
-    Alpine.magic('tabPanel', el => {
+    Alpine.magic('panel', el => {
         let $data = Alpine.$data(el)
         let $data = Alpine.$data(el)
 
 
         return {
         return {
-            get selected() {
+            get isSelected() {
                 return $data.__selectedIndex === $data.__panels.indexOf($data.__panelEl)
                 return $data.__selectedIndex === $data.__panels.indexOf($data.__panelEl)
             }
             }
         }
         }
@@ -31,6 +34,7 @@ export default function (Alpine) {
 
 
 function handleRoot(el, Alpine) {
 function handleRoot(el, Alpine) {
     Alpine.bind(el, {
     Alpine.bind(el, {
+        'x-modelable': '__selectedIndex',
         'x-data'() {
         'x-data'() {
             return {
             return {
                 init() {
                 init() {
@@ -71,16 +75,17 @@ function handleList(el, Alpine) {
 }
 }
 
 
 function handleTab(el, Alpine) {
 function handleTab(el, Alpine) {
-    let options = {}
     Alpine.bind(el, {
     Alpine.bind(el, {
         'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button' },
         'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button' },
         'x-data'() { return {
         'x-data'() { return {
             init() {
             init() {
                 this.__tabEl = this.$el
                 this.__tabEl = this.$el
                 this.$data.__addTab(this.$el)
                 this.$data.__addTab(this.$el)
-                this.$el.__disabled = options.disabled
+                this.__tabEl.__disabled = Alpine.bound(this.$el, 'disabled', false)
+                this.__isDisabled = this.__tabEl.__disabled
             },
             },
             __tabEl: undefined,
             __tabEl: undefined,
+            __isDisabled: false,
         }},
         }},
         '@click'() {
         '@click'() {
             if (this.$el.__disabled) return
             if (this.$el.__disabled) return
@@ -99,7 +104,7 @@ function handleTab(el, Alpine) {
         '@keydown.right.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.up.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).withWrapAround().prev() },
         '@keydown.left.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).withWrapAround().prev() },
         '@keydown.left.prevent.stop'() { this.$focus.within(this.$data.__activeTabs()).withWrapAround().prev() },
-        ':tabindex'() { return this.$tab.selected ? 0 : -1 },
+        ':tabindex'() { return this.$tab.isSelected ? 0 : -1 },
         '@focus'() {
         '@focus'() {
             if (this.$data.__manualActivation) {
             if (this.$data.__manualActivation) {
                 this.$el.focus()
                 this.$el.focus()
@@ -122,7 +127,7 @@ function handlePanels(el, Alpine) {
 
 
 function handlePanel(el, Alpine) {
 function handlePanel(el, Alpine) {
     Alpine.bind(el, {
     Alpine.bind(el, {
-        ':tabindex'() { return this.$tabPanel.selected ? 0 : -1 },
+        ':tabindex'() { return this.$panel.isSelected ? 0 : -1 },
         'x-data'() { return {
         'x-data'() { return {
             init() {
             init() {
                 this.__panelEl = this.$el
                 this.__panelEl = this.$el
@@ -130,7 +135,7 @@ function handlePanel(el, Alpine) {
             },
             },
             __panelEl: undefined,
             __panelEl: undefined,
         }},
         }},
-        'x-show'() { return this.$tabPanel.selected },
+        'x-show'() { return this.$panel.isSelected },
     })
     })
 }
 }
 
 

+ 1 - 1
scripts/build.js

@@ -116,4 +116,4 @@ function bytesToSize(bytes) {
     const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)), 10)
     const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)), 10)
     if (i === 0) return `${bytes} ${sizes[i]}`
     if (i === 0) return `${bytes} ${sizes[i]}`
     return `${(bytes / (1024 ** i)).toFixed(1)} ${sizes[i]}`
     return `${(bytes / (1024 ** i)).toFixed(1)} ${sizes[i]}`
-  }
+}

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

@@ -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'))
     }
     }
 )
 )

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

+ 55 - 0
tests/cypress/integration/directives/x-on.spec.js

@@ -275,6 +275,22 @@ test('.debounce modifier',
     }
     }
 )
 )
 
 
+test('.throttle modifier',
+    html`
+        <div x-data="{ count: 0 }">
+            <input x-on:keyup.throttle.504ms="count = count+1">
+            <span x-text="count"></span>
+        </div>
+    `,
+    ({ get }) => {
+        get('span').should(haveText('0'))
+        get('input').type('f')
+        get('span').should(haveText('1'))
+        get('input').type('ffffffffffff')
+        get('span').should(haveText('1'))
+    }
+)
+
 test('keydown modifiers',
 test('keydown modifiers',
     html`
     html`
         <div x-data="{ count: 0 }">
         <div x-data="{ count: 0 }">
@@ -330,6 +346,31 @@ test('keydown modifiers',
     }
     }
 )
 )
 
 
+test('discerns between space minus underscore',
+    html`
+        <div x-data="{ count: 0 }">
+            <input id="space" type="text" x-on:keydown.space="count++" />
+            <input id="minus" type="text" x-on:keydown.-="count++" />
+            <input id="underscore" type="text" x-on:keydown._="count++" />
+            <span x-text="count"></span>
+        </div>
+    `,
+    ({get}) => {
+        get('span').should(haveText('0'))
+        get('#space').type(' ')
+        get('span').should(haveText('1'))
+        get('#space').type('-')
+        get('span').should(haveText('1'))
+        get('#minus').type('-')
+        get('span').should(haveText('2'))
+        get('#minus').type(' ')
+        get('span').should(haveText('2'))
+        get('#underscore').type('_')
+        get('span').should(haveText('3'))
+        get('#underscore').type(' ')
+        get('span').should(haveText('3'))
+    })
+
 test('keydown combo modifiers',
 test('keydown combo modifiers',
     html`
     html`
         <div x-data="{ count: 0 }">
         <div x-data="{ count: 0 }">
@@ -493,3 +534,17 @@ test('.dot modifier correctly binds event listener with namespace',
         get('span').should(haveText('baz'))
         get('span').should(haveText('baz'))
     }
     }
 )
 )
+
+test('handles await in handlers with invalid right hand expressions',
+    html`
+        <div x-data="{ text: 'original' }">
+            <button @click="let value = 'new string'; text = await Promise.resolve(value)"></button>
+            <span x-text="text"></span>
+        </div>
+    `,
+    ({ get }) => {
+        get('span').should(haveText('original'))
+        get('button').click()
+        get('span').should(haveText('new string'))
+    }
+)

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

@@ -19,6 +19,44 @@ test('can use a x-teleport',
     },
     },
 )
 )
 
 
+test('can use a x-teleport.append',
+    [html`
+        <div x-data="{ count: 1 }" id="a">
+            <button @click="count++">Inc</button>
+
+            <template x-teleport.append="#b">
+                <span x-text="count"></span>
+            </template>
+        </div>
+
+        <div id="b"></div>
+    `],
+    ({ get }) => {
+        get('#b + span').should(haveText('1'))
+        get('button').click()
+        get('#b + span').should(haveText('2'))
+    },
+)
+
+test('can use a x-teleport.prepend',
+    [html`
+        <div x-data="{ count: 1 }" id="a">
+            <button @click="count++">Inc</button>
+
+            <template x-teleport.prepend="#b">
+                <span x-text="count"></span>
+            </template>
+        </div>
+
+        <div id="b"></div>
+    `],
+    ({ get }) => {
+        get('#a + span').should(haveText('1'))
+        get('button').click()
+        get('#a + span').should(haveText('2'))
+    },
+)
+
 test('can teleport multiple',
 test('can teleport multiple',
     [html`
     [html`
         <div x-data="{ count: 1 }" id="a">
         <div x-data="{ count: 1 }" id="a">

+ 25 - 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 }) => {
@@ -153,3 +167,14 @@ test('$money works with permenant inserted at beginning',
         get('input').should(haveValue('40.00'))
         get('input').should(haveValue('40.00'))
     }
     }
 )
 )
+
+test('$money mask should remove letters or non numeric characters',
+    [html`<input x-data x-mask:dynamic="$money">`],
+    ({ get }) => {
+        get('input').type('A').should(haveValue(''))
+        get('input').type('ABC').should(haveValue(''))
+        get('input').type('$').should(haveValue(''))
+        get('input').type('/').should(haveValue(''))
+        get('input').type('40').should(haveValue('40'))
+    }
+)

+ 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']))
+    },
+)

+ 56 - 6
tests/cypress/integration/plugins/ui/popover.spec.js

@@ -1,6 +1,6 @@
 import { beVisible, haveAttribute, html, notBeVisible, notHaveAttribute, test } from '../../../utils'
 import { beVisible, haveAttribute, html, notBeVisible, notHaveAttribute, test } from '../../../utils'
 
 
-test.skip('button toggles panel',
+test('button toggles panel',
     [html`
     [html`
         <div x-data x-popover>
         <div x-data x-popover>
             <button x-popover:button>Toggle</button>
             <button x-popover:button>Toggle</button>
@@ -19,7 +19,24 @@ test.skip('button toggles panel',
     },
     },
 )
 )
 
 
-test.skip('has accessibility attributes',
+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`
     [html`
         <div x-data x-popover>
         <div x-data x-popover>
             <button x-popover:button>Toggle</button>
             <button x-popover:button>Toggle</button>
@@ -38,7 +55,7 @@ test.skip('has accessibility attributes',
     },
     },
 )
 )
 
 
-test.skip('escape closes panel',
+test('escape closes panel',
     [html`
     [html`
         <div x-data x-popover>
         <div x-data x-popover>
             <button x-popover:button>Toggle</button>
             <button x-popover:button>Toggle</button>
@@ -57,7 +74,7 @@ test.skip('escape closes panel',
     },
     },
 )
 )
 
 
-test.skip('clicking outside closes panel',
+test('clicking outside closes panel',
     [html`
     [html`
         <div>
         <div>
             <div x-data x-popover>
             <div x-data x-popover>
@@ -80,7 +97,7 @@ test.skip('clicking outside closes panel',
     },
     },
 )
 )
 
 
-test.skip('focusing away closes panel',
+test('focusing away closes panel',
     [html`
     [html`
         <div>
         <div>
             <div x-data x-popover>
             <div x-data x-popover>
@@ -103,7 +120,7 @@ test.skip('focusing away closes panel',
     },
     },
 )
 )
 
 
-test.skip('focusing away doesnt close panel if focusing inside a group',
+test('focusing away doesnt close panel if focusing inside a group',
     [html`
     [html`
         <div x-data>
         <div x-data>
             <div x-popover:group>
             <div x-popover:group>
@@ -138,3 +155,36 @@ test.skip('focusing away doesnt close panel if focusing inside a group',
         get('#2 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())
+    },
+)

+ 144 - 4
tests/cypress/integration/plugins/ui/tabs.spec.js

@@ -1,6 +1,6 @@
-import { beVisible, haveFocus, html, notBeVisible, test } from '../../../utils'
+import { beVisible, haveClasses, haveFocus, html, notBeVisible, notHaveClasses, test } from '../../../utils'
 
 
-test.skip('can use tabs to toggle panels',
+test('can use tabs to toggle panels',
     [html`
     [html`
         <div x-data x-tabs>
         <div x-data x-tabs>
             <div x-tabs:list>
             <div x-tabs:list>
@@ -23,7 +23,7 @@ test.skip('can use tabs to toggle panels',
     },
     },
 )
 )
 
 
-test.skip('can use arrow keys to cycle through tabs',
+test('can use arrow keys to cycle through tabs',
     [html`
     [html`
         <div x-data x-tabs>
         <div x-data x-tabs>
             <div x-tabs:list>
             <div x-tabs:list>
@@ -55,7 +55,7 @@ test.skip('can use arrow keys to cycle through tabs',
     },
     },
 )
 )
 
 
-test.skip('cant tab through tabs, can only use arrows',
+test('cant tab through tabs, can only use arrows',
     [html`
     [html`
         <div>
         <div>
             <button button-1>first focusable</button>
             <button button-1>first focusable</button>
@@ -83,3 +83,143 @@ test.skip('cant tab through tabs, can only use arrows',
         get('[button-4]').should(haveFocus())
         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 - 1
tests/cypress/manual-memory.html

@@ -1,6 +1,6 @@
 <html>
 <html>
     <script src="/../../packages/alpinejs/dist/cdn.js" defer></script>
     <script src="/../../packages/alpinejs/dist/cdn.js" defer></script>
-    <link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
+    <link href="https://cdn.jsdelivr.net/npm/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
 
 
     <table class="w-dull">
     <table class="w-dull">
         <tr>
         <tr>

+ 1 - 1
tests/cypress/manual-transition-test.html

@@ -1,7 +1,7 @@
 <html>
 <html>
     <script src="/../../packages/collapse/dist/cdn.js" defer></script>
     <script src="/../../packages/collapse/dist/cdn.js" defer></script>
     <script src="/../../packages/alpinejs/dist/cdn.js" defer></script>
     <script src="/../../packages/alpinejs/dist/cdn.js" defer></script>
-    <link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
+    <link href="https://cdn.jsdelivr.net/npm/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
 
 
     <table>
     <table>
         <tr>
         <tr>

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

部分文件因为文件数量过多而无法显示