瀏覽代碼

Merge branch 'main' into bug/fix-error-msg-interpolation

Caleb Porzio 1 年之前
父節點
當前提交
9e0e49fce5
共有 100 個文件被更改,包括 2757 次插入2229 次删除
  1. 2 2
      benchmarks/giant.html
  2. 8 39
      index.html
  3. 12 14
      morph.html
  4. 57 11
      package-lock.json
  5. 2 1
      package.json
  6. 1 1
      packages/alpinejs/package.json
  7. 14 5
      packages/alpinejs/src/alpine.js
  8. 7 1
      packages/alpinejs/src/binds.js
  9. 30 2
      packages/alpinejs/src/clone.js
  10. 2 1
      packages/alpinejs/src/directives.js
  11. 18 2
      packages/alpinejs/src/directives/x-bind.js
  12. 28 2
      packages/alpinejs/src/directives/x-data.js
  13. 5 2
      packages/alpinejs/src/directives/x-effect.js
  14. 12 1
      packages/alpinejs/src/directives/x-id.js
  15. 3 0
      packages/alpinejs/src/directives/x-if.js
  16. 36 18
      packages/alpinejs/src/directives/x-model.js
  17. 1 1
      packages/alpinejs/src/directives/x-on.js
  18. 32 10
      packages/alpinejs/src/directives/x-teleport.js
  19. 1 1
      packages/alpinejs/src/directives/x-transition.js
  20. 18 20
      packages/alpinejs/src/entangle.js
  21. 16 5
      packages/alpinejs/src/evaluator.js
  22. 7 3
      packages/alpinejs/src/lifecycle.js
  23. 40 8
      packages/alpinejs/src/magics/$id.js
  24. 10 25
      packages/alpinejs/src/magics/$watch.js
  25. 1 1
      packages/alpinejs/src/magics/index.js
  26. 24 21
      packages/alpinejs/src/mutation.js
  27. 29 0
      packages/alpinejs/src/reactivity.js
  28. 50 61
      packages/alpinejs/src/scope.js
  29. 43 5
      packages/alpinejs/src/utils/bind.js
  30. 4 2
      packages/alpinejs/src/utils/error.js
  31. 0 17
      packages/alpinejs/src/utils/walk.js
  32. 5 0
      packages/anchor/builds/cdn.js
  33. 3 0
      packages/anchor/builds/module.js
  34. 17 0
      packages/anchor/package.json
  35. 77 0
      packages/anchor/src/index.js
  36. 1 1
      packages/collapse/package.json
  37. 3 3
      packages/csp/package.json
  38. 50 0
      packages/csp/src/evaluator.js
  39. 28 29
      packages/csp/src/index.js
  40. 1 1
      packages/docs/package.json
  41. 64 16
      packages/docs/src/en/advanced/csp.md
  42. 2 2
      packages/docs/src/en/advanced/extending.md
  43. 1 1
      packages/docs/src/en/advanced/reactivity.md
  44. 24 0
      packages/docs/src/en/directives/for.md
  45. 15 0
      packages/docs/src/en/directives/init.md
  46. 34 0
      packages/docs/src/en/directives/model.md
  47. 1 1
      packages/docs/src/en/directives/on.md
  48. 3 2
      packages/docs/src/en/essentials/installation.md
  49. 38 1
      packages/docs/src/en/globals/alpine-data.md
  50. 15 0
      packages/docs/src/en/magics/refs.md
  51. 1 1
      packages/docs/src/en/magics/watch.md
  52. 213 0
      packages/docs/src/en/plugins/anchor.md
  53. 1 1
      packages/docs/src/en/plugins/morph.md
  54. 3 2
      packages/focus/package.json
  55. 5 4
      packages/focus/src/index.js
  56. 2 0
      packages/history/builds/module.js
  57. 1 76
      packages/history/src/index.js
  58. 0 36
      packages/history/src/url.js
  59. 1 1
      packages/intersect/package.json
  60. 2 2
      packages/intersect/src/index.js
  61. 1 1
      packages/mask/package.json
  62. 19 5
      packages/mask/src/index.js
  63. 1 1
      packages/morph/package.json
  64. 0 75
      packages/morph/src/dom.js
  65. 173 89
      packages/morph/src/morph.js
  66. 1 1
      packages/morph/src/old_morph.js
  67. 0 156
      packages/navigate/src/bar.js
  68. 0 9
      packages/navigate/src/bus.js
  69. 0 6
      packages/navigate/src/fetch.js
  70. 0 71
      packages/navigate/src/history.js
  71. 1 219
      packages/navigate/src/index.js
  72. 0 51
      packages/navigate/src/links.js
  73. 0 106
      packages/navigate/src/page.js
  74. 0 27
      packages/navigate/src/persist.js
  75. 0 45
      packages/navigate/src/prefetch.js
  76. 0 24
      packages/navigate/src/scroll.js
  77. 1 1
      packages/persist/package.json
  78. 15 1
      packages/persist/src/index.js
  79. 0 1
      packages/ui/demo/listbox/data-driven.html
  80. 0 1
      packages/ui/demo/listbox/index.html
  81. 0 1
      packages/ui/demo/listbox/multiple.html
  82. 2 2
      packages/ui/package.json
  83. 381 429
      packages/ui/src/combobox.js
  84. 9 5
      packages/ui/src/disclosure.js
  85. 2 0
      packages/ui/src/index.js
  86. 227 225
      packages/ui/src/list-context.js
  87. 221 90
      packages/ui/src/listbox.js
  88. 18 7
      packages/ui/src/menu.js
  89. 3 4
      scripts/build.js
  90. 12 0
      scripts/release.js
  91. 15 0
      tests/cypress/integration/directives/x-bind.spec.js
  92. 93 2
      tests/cypress/integration/directives/x-model.spec.js
  93. 14 0
      tests/cypress/integration/directives/x-on.spec.js
  94. 35 0
      tests/cypress/integration/entangle.spec.js
  95. 32 1
      tests/cypress/integration/magics/$id.spec.js
  96. 13 0
      tests/cypress/integration/plugins/anchor.spec.js
  97. 1 0
      tests/cypress/integration/plugins/focus.spec.js
  98. 219 98
      tests/cypress/integration/plugins/history.spec.js
  99. 16 1
      tests/cypress/integration/plugins/mask.spec.js
  100. 108 12
      tests/cypress/integration/plugins/morph.spec.js

+ 2 - 2
benchmarks/giant.html

@@ -1689,7 +1689,7 @@
                         <div class="compare-show-header Subhead hx_Subhead--responsive ">
                             <h1 class="Subhead-heading ">Comparing changes</h1>
 
-                            <div class="Subhead-description "> Choose two branches to see whats changed or to start a
+                            <div class="Subhead-description "> Choose two branches to see what's changed or to start a
                                 new pull request.
                                 If you need to, you can also <button type="button"
                                     class="btn-link js-toggle-range-editor-cross-repo">compare across forks</button>.
@@ -25559,7 +25559,7 @@ Co-authored-by: Caleb Porzio &lt;calebporzio@gmail.com&gt;</pre>
                 </path>
             </svg>
         </button>
-        You cant perform that action at this time.
+        You can't perform that action at this time.
     </div>
 
     <div class="js-stale-session-flash flash flash-warn flash-banner" hidden>

+ 8 - 39
index.html

@@ -1,51 +1,20 @@
 <html>
     <!-- <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/anchor/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>
-
-    <div x-data="{ users: [{ name: 'lebowski' }] }">
-        <template x-for="(user, idx) in users">
-            <span x-text="users[idx].name" x-yo></span>
-        </template>
-
-        <button @click="users = []">Reset</button>
-    </div>
-
-    <!-- Play around here... -->
-
-    <div x-data>
-        <div id="thing" x-yo>i do not belong here...</div>
-
-        <br>
-        <br>
-        <br>
-        <br>
-
-        <button @click="document.getElementById('thing').remove()">remove</button>
-    </div>
-
-    <script>
-        document.addEventListener('alpine:init', () => {
-            Alpine.directive('yo', (el, {}, { cleanup }) => {
-                cleanup(() => {
-                    console.log('removed')
-                })
-            })
-        })
-    </script>
-
-    <div x-data="{ users: [{ name: 'lebowski' }] }">
-        <template x-for="(user, idx) in users">
-            <span x-text="users[idx].name" x-yo></span>
-        </template>
+    <!-- <script src="//cdn.tailwindcss.com"></script> -->
 
-        <button @click="users = []">Reset</button>
+    <div x-data="{ val: true }"
+    >
+   <input type="text" x-model.boolean="val">
+   <input type="checkbox" x-model.boolean="val">
+   <input type="radio" name="foo" value="true" x-model.boolean="val">
+   <input type="radio" name="foo" value="false" x-model.boolean="val">
     </div>
 </html>

+ 12 - 14
morph.html

@@ -5,25 +5,22 @@
 
 <div id="before">
 <!-- Before markup goes here: -->
-<button>
-    <div>
-        <div>second</div>
-        <div>third</div>
-    </div>
-</button>
+<ul>
+    <li data-key="1">foo<input></li>
+</ul>
 </div>
 
 <div id="after" style="display: none;">
 <!-- After markup goes here: -->
-<button>
-    <div>first</div>
-    <div>
-        <div>second</div>
-        <div>third</div>
-    </div>
-</button>
+<ul>
+    <li data-key="2">bar<input></li>
+    <li data-key="3">baz<input></li>
+    <li data-key="1">foo<input></li>
+</ul>
 </div>
 
+<div id="b"></div>
+
     <div style="display: flex;">
         <pre id="log-from"></pre>
         <pre id="log-to"></pre>
@@ -38,6 +35,7 @@
     <script>
         function start() {
             Alpine.morph.log((message, from, to) => {
+                console.log(message, from, to)
                 document.querySelector('#log-from').innerHTML = escape(from.outerHTML)
                 document.querySelector('#log-to').innerHTML = escape(to.outerHTML)
                 let li = document.createElement('li')
@@ -48,7 +46,7 @@
             Alpine.morph(
                 document.querySelector('#before').firstElementChild,
                 document.querySelector('#after').firstElementChild.outerHTML,
-                { debug: true }
+                { debug: true, key(el) { return el.dataset.key } }
             )
         }
 

+ 57 - 11
package-lock.json

@@ -7,6 +7,9 @@
             "workspaces": [
                 "packages/*"
             ],
+            "dependencies": {
+                "@floating-ui/dom": "^1.5.3"
+            },
             "devDependencies": {
                 "axios": "^0.21.1",
                 "chalk": "^4.1.1",
@@ -17,6 +20,10 @@
                 "jest": "^26.6.3"
             }
         },
+        "node_modules/@alpinejs/anchor": {
+            "resolved": "packages/anchor",
+            "link": true
+        },
         "node_modules/@alpinejs/collapse": {
             "resolved": "packages/collapse",
             "link": true
@@ -1014,6 +1021,28 @@
                 "node": ">=12"
             }
         },
+        "node_modules/@floating-ui/core": {
+            "version": "1.5.0",
+            "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.0.tgz",
+            "integrity": "sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==",
+            "dependencies": {
+                "@floating-ui/utils": "^0.1.3"
+            }
+        },
+        "node_modules/@floating-ui/dom": {
+            "version": "1.5.3",
+            "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz",
+            "integrity": "sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==",
+            "dependencies": {
+                "@floating-ui/core": "^1.4.2",
+                "@floating-ui/utils": "^0.1.3"
+            }
+        },
+        "node_modules/@floating-ui/utils": {
+            "version": "0.1.6",
+            "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz",
+            "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A=="
+        },
         "node_modules/@istanbuljs/load-nyc-config": {
             "version": "1.1.0",
             "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -7825,17 +7854,23 @@
             }
         },
         "packages/alpinejs": {
-            "version": "3.12.0",
+            "version": "3.13.2",
             "license": "MIT",
             "dependencies": {
                 "@vue/reactivity": "~3.1.1"
             }
         },
+        "packages/anchor": {
+            "version": "3.13.2",
+            "license": "MIT"
+        },
         "packages/collapse": {
-            "version": "3.12.0",
+            "name": "@alpinejs/collapse",
+            "version": "3.13.2",
             "license": "MIT"
         },
         "packages/csp": {
+            "name": "@alpinejs/csp",
             "version": "3.0.0-alpha.0",
             "license": "MIT",
             "dependencies": {
@@ -7843,17 +7878,21 @@
             }
         },
         "packages/docs": {
-            "version": "3.12.0-revision.1",
+            "name": "@alpinejs/docs",
+            "version": "3.13.2-revision.1",
             "license": "MIT"
         },
         "packages/focus": {
-            "version": "3.12.0",
+            "name": "@alpinejs/focus",
+            "version": "3.13.2",
             "license": "MIT",
             "dependencies": {
-                "focus-trap": "^6.6.1"
+                "focus-trap": "^6.9.4",
+                "tabbable": "^5.3.3"
             }
         },
         "packages/history": {
+            "name": "@alpinejs/history",
             "version": "3.0.0-alpha.0",
             "license": "MIT",
             "dependencies": {
@@ -7861,18 +7900,22 @@
             }
         },
         "packages/intersect": {
-            "version": "3.12.0",
+            "name": "@alpinejs/intersect",
+            "version": "3.13.2",
             "license": "MIT"
         },
         "packages/mask": {
-            "version": "3.12.0",
+            "name": "@alpinejs/mask",
+            "version": "3.13.2",
             "license": "MIT"
         },
         "packages/morph": {
-            "version": "3.12.0",
+            "name": "@alpinejs/morph",
+            "version": "3.13.2",
             "license": "MIT"
         },
         "packages/navigate": {
+            "name": "@alpinejs/navigate",
             "version": "3.10.2",
             "license": "MIT",
             "dependencies": {
@@ -7880,12 +7923,15 @@
             }
         },
         "packages/persist": {
-            "version": "3.12.0",
+            "name": "@alpinejs/persist",
+            "version": "3.13.2",
             "license": "MIT"
         },
         "packages/ui": {
-            "version": "3.12.0-beta.0",
-            "license": "MIT"
+            "name": "@alpinejs/ui",
+            "version": "3.13.1-beta.0",
+            "license": "MIT",
+            "devDependencies": {}
         }
     }
 }

+ 2 - 1
package.json

@@ -8,6 +8,7 @@
         "chalk": "^4.1.1",
         "cypress": "^7.0.0",
         "cypress-plugin-tab": "^1.0.5",
+        "@floating-ui/dom": "^1.5.3",
         "dot-json": "^1.2.2",
         "esbuild": "~0.16.17",
         "jest": "^26.6.3"
@@ -15,7 +16,7 @@
     "scripts": {
         "build": "node ./scripts/build.js",
         "watch": "node ./scripts/build.js --watch",
-        "test": "cypress run",
+        "test": "cypress run --quiet",
         "cypress": "cypress open",
         "jest": "jest test",
         "update-docs": "node ./scripts/update-docs.js",

+ 1 - 1
packages/alpinejs/package.json

@@ -1,6 +1,6 @@
 {
     "name": "alpinejs",
-    "version": "3.12.1",
+    "version": "3.13.3",
     "description": "The rugged, minimal JavaScript framework",
     "homepage": "https://alpinejs.dev",
     "repository": {

+ 14 - 5
packages/alpinejs/src/alpine.js

@@ -1,16 +1,17 @@
-import { setReactivityEngine, disableEffectScheduling, reactive, effect, release, raw } from './reactivity'
+import { setReactivityEngine, disableEffectScheduling, reactive, effect, release, raw, watch } from './reactivity'
 import { mapAttributes, directive, setPrefix as prefix, prefix as prefixed } from './directives'
 import { start, addRootSelector, addInitSelector, closestRoot, findClosest, initTree, destroyTree, interceptInit } from './lifecycle'
-import { mutateDom, deferMutations, flushAndStopDeferringMutations, startObservingMutations, stopObservingMutations } from './mutation'
+import { onElRemoved, onAttributeRemoved, onAttributesAdded, mutateDom, deferMutations, flushAndStopDeferringMutations, startObservingMutations, stopObservingMutations } from './mutation'
 import { mergeProxies, closestDataStack, addScopeToNode, scope as $data } from './scope'
 import { setEvaluator, evaluate, evaluateLater, dontAutoEvaluateFunctions } from './evaluator'
 import { transition } from './directives/x-transition'
-import { clone, skipDuringClone, onlyDuringClone } from './clone'
+import { clone, cloneNode, skipDuringClone, onlyDuringClone, interceptClone } from './clone'
 import { interceptor } from './interceptor'
-import { getBinding as bound } from './utils/bind'
+import { getBinding as bound, extractProp } from './utils/bind'
 import { debounce } from './utils/debounce'
 import { throttle } from './utils/throttle'
 import { setStyles } from './utils/styles'
+import { entangle } from './entangle'
 import { nextTick } from './nextTick'
 import { walk } from './utils/walk'
 import { plugin } from './plugin'
@@ -31,11 +32,14 @@ let Alpine = {
     startObservingMutations,
     stopObservingMutations,
     setReactivityEngine,
+    onAttributeRemoved,
+    onAttributesAdded,
     closestDataStack,
     skipDuringClone,
     onlyDuringClone,
     addRootSelector,
     addInitSelector,
+    interceptClone,
     addScopeToNode,
     deferMutations,
     mapAttributes,
@@ -43,7 +47,9 @@ let Alpine = {
     interceptInit,
     setEvaluator,
     mergeProxies,
+    extractProp,
     findClosest,
+    onElRemoved,
     closestRoot,
     destroyTree,
     interceptor, // INTERNAL: not public API and is subject to change without major release.
@@ -51,6 +57,7 @@ let Alpine = {
     setStyles, // INTERNAL
     mutateDom,
     directive,
+    entangle,
     throttle,
     debounce,
     evaluate,
@@ -62,9 +69,11 @@ let Alpine = {
     magic,
     store,
     start,
-    clone,
+    clone, // INTERNAL
+    cloneNode, // INTERNAL
     bound,
     $data,
+    watch,
     walk,
     data,
     bind,

+ 7 - 1
packages/alpinejs/src/binds.js

@@ -6,10 +6,12 @@ export function bind(name, bindings) {
     let getBindings = typeof bindings !== 'function' ? () => bindings : bindings
 
     if (name instanceof Element) {
-        applyBindingsObject(name, getBindings())
+        return applyBindingsObject(name, getBindings())
     } else {
         binds[name] = getBindings
     }
+
+    return () => {} // Null cleanup...
 }
 
 export function injectBindingProviders(obj) {
@@ -58,4 +60,8 @@ export function applyBindingsObject(el, obj, original) {
 
         handle()
     })
+
+    return () => {
+        while (cleanupRunners.length) cleanupRunners.pop()()
+    }
 }

+ 30 - 2
packages/alpinejs/src/clone.js

@@ -12,22 +12,50 @@ export function onlyDuringClone(callback) {
     return (...args) => isCloning && callback(...args)
 }
 
-export function interuptCrawl(callback) {
-    return (...args) => isCloning || callback(...args)
+let interceptors = []
+
+export function interceptClone(callback) {
+    interceptors.push(callback)
+}
+
+export function cloneNode(from, to)
+{
+    interceptors.forEach(i => i(from, to))
+
+    isCloning = true
+
+    // We don't need reactive effects in the new tree.
+    // Cloning is just used to seed new server HTML with
+    // Alpine before "morphing" it onto live Alpine...
+    dontRegisterReactiveSideEffects(() => {
+        initTree(to, (el, callback) => {
+            // We're hijacking the "walker" so that we
+            // only initialize the element we're cloning...
+            callback(el, () => {})
+        })
+    })
+
+    isCloning = false
 }
 
+export let isCloningLegacy = false
+
+/** deprecated */
 export function clone(oldEl, newEl) {
     if (! newEl._x_dataStack) newEl._x_dataStack = oldEl._x_dataStack
 
     isCloning = true
+    isCloningLegacy = true
 
     dontRegisterReactiveSideEffects(() => {
         cloneTree(newEl)
     })
 
     isCloning = false
+    isCloningLegacy = false
 }
 
+/** deprecated */
 export function cloneTree(el) {
     let hasRunThroughFirstEl = false
 

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

@@ -179,7 +179,7 @@ let alpineAttributeRegex = () => (new RegExp(`^${prefixAsString}([^:^.]+)\\b`))
 function toParsedDirectives(transformedAttributeMap, originalAttributeOverride) {
     return ({ name, value }) => {
         let typeMatch = name.match(alpineAttributeRegex())
-        let valueMatch = name.match(/:([a-zA-Z0-9\-:]+)/)
+        let valueMatch = name.match(/:([a-zA-Z0-9\-_:]+)/)
         let modifiers = name.match(/\.[^.\]]+(?=[^\]]*$)/g) || []
         let original = originalAttributeOverride || transformedAttributeMap[name] || name
 
@@ -200,6 +200,7 @@ let directiveOrder = [
     'ref',
     'data',
     'id',
+    'anchor',
     'bind',
     'init',
     'for',

+ 18 - 2
packages/alpinejs/src/directives/x-bind.js

@@ -6,7 +6,7 @@ import { applyBindingsObject, injectBindingProviders } from '../binds'
 
 mapAttributes(startingWith(':', into(prefix('bind:'))))
 
-directive('bind', (el, { value, modifiers, expression, original }, { effect }) => {
+let handler = (el, { value, modifiers, expression, original }, { effect }) => {
     if (! value) {
         let bindingProviders = {}
         injectBindingProviders(bindingProviders)
@@ -22,6 +22,10 @@ directive('bind', (el, { value, modifiers, expression, original }, { effect }) =
 
     if (value === 'key') return storeKeyForXFor(el, expression)
 
+    if (el._x_inlineBindings && el._x_inlineBindings[value] && el._x_inlineBindings[value].extract) {
+        return
+    }
+
     let evaluate = evaluateLater(el, expression)
 
     effect(() => evaluate(result => {
@@ -32,7 +36,19 @@ directive('bind', (el, { value, modifiers, expression, original }, { effect }) =
 
         mutateDom(() => bind(el, value, result, modifiers))
     }))
-})
+}
+
+// @todo: see if I can take advantage of the object created here inside the
+// non-inline handler above so we're not duplicating work twice...
+handler.inline = (el, { value, modifiers, expression }) => {
+    if (! value) return;
+
+    if (! el._x_inlineBindings) el._x_inlineBindings = {}
+
+    el._x_inlineBindings[value] = { expression, extract: false }
+}
+
+directive('bind', handler)
 
 function storeKeyForXFor(el, expression) {
     el._x_keyExpression = expression

+ 28 - 2
packages/alpinejs/src/directives/x-data.js

@@ -2,7 +2,7 @@ import { directive, prefix } from '../directives'
 import { initInterceptors } from '../interceptor'
 import { injectDataProviders } from '../datas'
 import { addRootSelector } from '../lifecycle'
-import { skipDuringClone } from '../clone'
+import { interceptClone, isCloning, isCloningLegacy } from '../clone'
 import { addScopeToNode } from '../scope'
 import { injectMagics, magic } from '../magics'
 import { reactive } from '../reactivity'
@@ -10,7 +10,9 @@ import { evaluate } from '../evaluator'
 
 addRootSelector(() => `[${prefix('data')}]`)
 
-directive('data', skipDuringClone((el, { expression }, { cleanup }) => {
+directive('data', ((el, { expression }, { cleanup }) => {
+    if (shouldSkipRegisteringDataDuringClone(el)) return
+
     expression = expression === '' ? '{}' : expression
 
     let magicContext = {}
@@ -39,3 +41,27 @@ directive('data', skipDuringClone((el, { expression }, { cleanup }) => {
         undo()
     })
 }))
+
+interceptClone((from, to) => {
+    // Transfer over existing runtime Alpine state from
+    // the existing dom tree over to the new one...
+    if (from._x_dataStack) {
+        to._x_dataStack = from._x_dataStack
+
+        // Set a flag to signify the new tree is using
+        // pre-seeded state (used so x-data knows when
+        // and when not to initialize state)...
+        to.setAttribute('data-has-alpine-state', true)
+    }
+})
+
+// If we are cloning a tree, we only want to evaluate x-data if another
+// x-data context DOESN'T exist on the component.
+// The reason a data context WOULD exist is that we graft root x-data state over
+// from the live tree before hydrating the clone tree.
+function shouldSkipRegisteringDataDuringClone(el) {
+    if (! isCloning) return false
+    if (isCloningLegacy) return true
+
+    return el.hasAttribute('data-has-alpine-state')
+}

+ 5 - 2
packages/alpinejs/src/directives/x-effect.js

@@ -1,4 +1,7 @@
+import { skipDuringClone } from '../clone'
 import { directive } from '../directives'
-import { evaluateLater } from '../evaluator'
+import { evaluate, evaluateLater } from '../evaluator'
 
-directive('effect', (el, { expression }, { effect }) => effect(evaluateLater(el, expression)))
+directive('effect', skipDuringClone((el, { expression }, { effect }) => {
+    effect(evaluateLater(el, expression))
+}))

+ 12 - 1
packages/alpinejs/src/directives/x-id.js

@@ -1,8 +1,19 @@
+import { interceptClone } from "../clone"
 import { directive } from "../directives"
 import { setIdRoot } from '../ids'
 
 directive('id', (el, { expression }, { evaluate }) => {
     let names = evaluate(expression)
-    
+
     names.forEach(name => setIdRoot(el, name))
 })
+
+interceptClone((from, to) => {
+    // Transfer over existing ID registrations from
+    // the existing dom tree over to the new one
+    // so that there aren't ID mismatches...
+    if (from._x_ids) {
+        to._x_ids = from._x_ids
+    }
+})
+

+ 3 - 0
packages/alpinejs/src/directives/x-if.js

@@ -5,8 +5,11 @@ import { initTree } from '../lifecycle'
 import { mutateDom } from '../mutation'
 import { walk } from "../utils/walk"
 import { dequeueJob } from '../scheduler'
+import { warn } from "../utils/warn"
 
 directive('if', (el, { expression }, { effect, cleanup }) => {
+    if (el.tagName.toLowerCase() !== 'template') warn('x-if can only be used on a <template> tag', el)
+
     let evaluate = evaluateLater(el, expression)
 
     let show = () => {

+ 36 - 18
packages/alpinejs/src/directives/x-model.js

@@ -2,7 +2,7 @@ import { evaluateLater } from '../evaluator'
 import { directive } from '../directives'
 import { mutateDom } from '../mutation'
 import { nextTick } from '../nextTick'
-import bind from '../utils/bind'
+import bind, { safeParseBoolean } from '../utils/bind'
 import on from '../utils/on'
 import { warn } from '../utils/warn'
 import { isCloning } from '../clone'
@@ -46,7 +46,7 @@ directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
             })
         }
     }
-    
+
     if (typeof expression === 'string' && el.type === 'radio') {
         // Radio buttons only work properly when they share a name attribute.
         // People might assume we take care of that for them, because
@@ -69,9 +69,11 @@ directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
     let removeListener = isCloning ? () => {} : on(el, event, modifiers, (e) => {
         setValue(getInputValue(el, modifiers, e, getValue()))
     })
-    
-    if (modifiers.includes('fill') && [null, ''].includes(getValue())) {
-        el.dispatchEvent(new Event(event, {}));
+
+    if (modifiers.includes('fill'))
+        if ([null, ''].includes(getValue())
+            || (el.type === 'checkbox' && Array.isArray(getValue()))) {
+            el.dispatchEvent(new Event(event, {}));
     }
     // Register the listener removal callback on the element, so that
     // in addition to the cleanup function, x-modelable may call it.
@@ -93,7 +95,7 @@ directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
         cleanup(() => removeResetListener())
     }
 
-    // Allow programmatic overiding of x-model.
+    // Allow programmatic overriding of x-model.
     el._x_model = {
         get() {
             return getValue()
@@ -104,8 +106,6 @@ directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
     }
 
     el._x_forceModelUpdate = (value) => {
-        value = value === undefined ? getValue() : value
-
         // If nested model key is undefined, set the default value to empty string.
         if (value === undefined && typeof expression === 'string' && expression.match(/\./)) value = ''
 
@@ -134,30 +134,48 @@ function getInputValue(el, modifiers, event, currentValue) {
         // Safari autofill triggers event as CustomEvent and assigns value to target
         // so we return event.target.value instead of event.detail
         if (event instanceof CustomEvent && event.detail !== undefined)
-            return event.detail ?? event.target.value
+            return event.detail !== null && event.detail !== undefined ? event.detail : event.target.value
         else if (el.type === 'checkbox') {
             // If the data we are binding to is an array, toggle its value inside the array.
             if (Array.isArray(currentValue)) {
-                let newValue = modifiers.includes('number') ? safeParseNumber(event.target.value) : event.target.value
+                let newValue = null;
+
+                if (modifiers.includes('number')) {
+                    newValue = safeParseNumber(event.target.value)
+                } else if (modifiers.includes('boolean')) {
+                    newValue = safeParseBoolean(event.target.value)
+                } else {
+                    newValue = event.target.value
+                }
 
                 return event.target.checked ? currentValue.concat([newValue]) : currentValue.filter(el => ! checkedAttrLooseCompare(el, newValue))
             } else {
                 return event.target.checked
             }
         } else if (el.tagName.toLowerCase() === 'select' && el.multiple) {
-            return modifiers.includes('number')
-                ? Array.from(event.target.selectedOptions).map(option => {
+            if (modifiers.includes('number')) {
+                return Array.from(event.target.selectedOptions).map(option => {
                     let rawValue = option.value || option.text
                     return safeParseNumber(rawValue)
                 })
-                : Array.from(event.target.selectedOptions).map(option => {
-                    return option.value || option.text
+            } else if (modifiers.includes('boolean')) {
+                return Array.from(event.target.selectedOptions).map(option => {
+                    let rawValue = option.value || option.text
+                    return safeParseBoolean(rawValue)
                 })
+            }
+
+            return Array.from(event.target.selectedOptions).map(option => {
+                return option.value || option.text
+            })
         } else {
-            let rawValue = event.target.value
-            return modifiers.includes('number')
-                ? safeParseNumber(rawValue)
-                : (modifiers.includes('trim') ? rawValue.trim() : rawValue)
+            if (modifiers.includes('number')) {
+                return safeParseNumber(event.target.value)
+            } else if (modifiers.includes('boolean')) {
+                return safeParseBoolean(event.target.value)
+            }
+
+            return modifiers.includes('trim') ? event.target.value.trim() : event.target.value
         }
     })
 }

+ 1 - 1
packages/alpinejs/src/directives/x-on.js

@@ -7,7 +7,7 @@ mapAttributes(startingWith('@', into(prefix('on:'))))
 
 directive('on', skipDuringClone((el, { value, modifiers, expression }, { cleanup }) => {
     let evaluate = expression ? evaluateLater(el, expression) : () => {}
-   
+
     // Forward event listeners on portals.
     if (el.tagName.toLowerCase() === 'template') {
         if (! el._x_forwardEvents) el._x_forwardEvents = []

+ 32 - 10
packages/alpinejs/src/directives/x-teleport.js

@@ -5,18 +5,10 @@ import { mutateDom } from "../mutation"
 import { addScopeToNode } from "../scope"
 import { warn } from "../utils/warn"
 
-let teleportContainerDuringClone = document.createElement('div')
-
 directive('teleport', (el, { modifiers, expression }, { cleanup }) => {
     if (el.tagName.toLowerCase() !== 'template') warn('x-teleport can only be used on a <template> tag', el)
 
-    let target = skipDuringClone(() => {
-        return document.querySelector(expression)
-    }, () => {
-        return teleportContainerDuringClone
-    })()
-
-    if (! target) warn(`Cannot find x-teleport element for selector: "${expression}"`)
+    let target = getTarget(expression)
 
     let clone = el.content.cloneNode(true).firstElementChild
 
@@ -24,6 +16,10 @@ directive('teleport', (el, { modifiers, expression }, { cleanup }) => {
     el._x_teleport = clone
     clone._x_teleportBack = el
 
+    // Add the key to the DOM so they can be more easily searched for and linked up...
+    el.setAttribute('data-teleport-template', true)
+    clone.setAttribute('data-teleport-target', true)
+
     // Forward event listeners:
     if (el._x_forwardEvents) {
         el._x_forwardEvents.forEach(eventName => {
@@ -37,7 +33,7 @@ directive('teleport', (el, { modifiers, expression }, { cleanup }) => {
 
     addScopeToNode(clone, {}, el)
 
-    mutateDom(() => {
+    let placeInDom = (clone, target, modifiers) => {
         if (modifiers.includes('prepend')) {
             // insert element before the target
             target.parentNode.insertBefore(clone, target)
@@ -48,11 +44,37 @@ directive('teleport', (el, { modifiers, expression }, { cleanup }) => {
             // origin
             target.appendChild(clone)
         }
+    }
+
+    mutateDom(() => {
+        placeInDom(clone, target, modifiers)
 
         initTree(clone)
 
         clone._x_ignore = true
     })
 
+    el._x_teleportPutBack = () => {
+        let target = getTarget(expression)
+
+        mutateDom(() => {
+            placeInDom(el._x_teleport, target, modifiers)
+        })
+    }
+
     cleanup(() => clone.remove())
 })
+
+let teleportContainerDuringClone = document.createElement('div')
+
+function getTarget(expression) {
+    let target = skipDuringClone(() => {
+        return document.querySelector(expression)
+    }, () => {
+        return teleportContainerDuringClone
+    })()
+
+    if (! target) warn(`Cannot find x-teleport element for selector: "${expression}"`)
+
+    return target
+}

+ 1 - 1
packages/alpinejs/src/directives/x-transition.js

@@ -156,7 +156,7 @@ window.Element.prototype._x_toggleAndCascadeWithTransitions = function (el, valu
         ? new Promise((resolve, reject) => {
             el._x_transition.out(() => {}, () => resolve(hide))
 
-            el._x_transitioning.beforeCancel(() => reject({ isFromCancelledTransition: true }))
+            el._x_transitioning && el._x_transitioning.beforeCancel(() => reject({ isFromCancelledTransition: true }))
         })
         : Promise.resolve(hide)
 

+ 18 - 20
packages/alpinejs/src/entangle.js

@@ -2,39 +2,37 @@ import { effect, release } from './reactivity'
 
 export function entangle({ get: outerGet, set: outerSet }, { get: innerGet, set: innerSet }) {
     let firstRun = true
-    let outerHash, innerHash, outerHashLatest, innerHashLatest
+    let outerHash
 
     let reference = effect(() => {
-        let outer, inner
-
+        const outer = outerGet()
+        const inner = innerGet()
         if (firstRun) {
-            outer = outerGet()
-            innerSet(outer)
-            inner = innerGet()
+            innerSet(cloneIfObject(outer))
             firstRun = false
+            outerHash = JSON.stringify(outer)
         } else {
-            outer = outerGet()
-            inner = innerGet()
-
-            outerHashLatest = JSON.stringify(outer)
-            innerHashLatest = JSON.stringify(inner)
+            const outerHashLatest = JSON.stringify(outer)
 
             if (outerHashLatest !== outerHash) { // If outer changed...
-                inner = innerGet()
-                innerSet(outer)
-                inner = outer // Assign inner to outer so that it can be serialized for diffing...
+                innerSet(cloneIfObject(outer))
+                outerHash = outerHashLatest
             } else { // If inner changed...
-                outerSet(inner)
-                outer = inner // Assign outer to inner so that it can be serialized for diffing...
+                outerSet(cloneIfObject(inner))
+                outerHash = JSON.stringify(inner)
             }
         }
-
-        // Re serialize values...
-        outerHash = JSON.stringify(outer)
-        innerHash = JSON.stringify(inner)
+        JSON.stringify(innerGet())
+        JSON.stringify(outerGet())
     })
 
     return () => {
         release(reference)
     }
 }
+
+function cloneIfObject(value) {
+    return typeof value === 'object'
+        ? JSON.parse(JSON.stringify(value))
+        : value
+}

+ 16 - 5
packages/alpinejs/src/evaluator.js

@@ -9,9 +9,11 @@ export function dontAutoEvaluateFunctions(callback) {
 
     shouldAutoEvaluateFunctions = false
 
-    callback()
+    let result = callback()
 
     shouldAutoEvaluateFunctions = cache
+
+    return result
 }
 
 export function evaluate(el, expression, extras = {}) {
@@ -64,19 +66,28 @@ function generateFunctionFromString(expression, el) {
     let AsyncFunction = Object.getPrototypeOf(async function(){}).constructor
 
     // Some expressions that are useful in Alpine are not valid as the right side of an expression.
-    // Here we'll detect if the expression isn't valid for an assignement and wrap it in a self-
+    // Here we'll detect if the expression isn't valid for an assignment and wrap it in a self-
     // calling function so that we don't throw an error AND a "return" statement can b e used.
     let rightSideSafeExpression = 0
         // Support expressions starting with "if" statements like: "if (...) doSomething()"
-        || /^[\n\s]*if.*\(.*\)/.test(expression)
+        || /^[\n\s]*if.*\(.*\)/.test(expression.trim())
         // Support expressions starting with "let/const" like: "let foo = 'bar'"
-        || /^(let|const)\s/.test(expression)
+        || /^(let|const)\s/.test(expression.trim())
             ? `(async()=>{ ${expression} })()`
             : expression
 
     const safeAsyncFunction = () => {
         try {
-            return new AsyncFunction(['__self', 'scope'], `with (scope) { __self.result = ${rightSideSafeExpression} }; __self.finished = true; return __self.result;`)
+            let func = new AsyncFunction(
+                ["__self", "scope"],
+                `with (scope) { __self.result = ${rightSideSafeExpression} }; __self.finished = true; return __self.result;`
+            )
+            
+            Object.defineProperty(func, "name", {
+                value: `[Alpine] ${expression}`,
+            })
+            
+            return func
         } catch ( error ) {
             handleError( error, el, expression )
             return Promise.resolve()

+ 7 - 3
packages/alpinejs/src/lifecycle.js

@@ -1,8 +1,9 @@
-import { startObservingMutations, onAttributesAdded, onElAdded, onElRemoved, cleanupAttributes } from "./mutation"
+import { startObservingMutations, onAttributesAdded, onElAdded, onElRemoved, cleanupAttributes, cleanupElement } from "./mutation"
 import { deferHandlingDirectives, directives } from "./directives"
 import { dispatch } from './utils/dispatch'
 import { walk } from "./utils/walk"
 import { warn } from './utils/warn'
+import Alpine from "./alpine"
 
 let started = false
 
@@ -26,7 +27,7 @@ export function start() {
     })
 
     let outNestedComponents = el => ! closestRoot(el.parentElement, true)
-    Array.from(document.querySelectorAll(allSelectors()))
+    Array.from(document.querySelectorAll(allSelectors().join(',')))
         .filter(outNestedComponents)
         .forEach(el => {
             initTree(el)
@@ -93,5 +94,8 @@ export function initTree(el, walker = walk, intercept = () => {}) {
 }
 
 export function destroyTree(root) {
-    walk(root, el => cleanupAttributes(el))
+    walk(root, el => {
+        cleanupAttributes(el)
+        cleanupElement(el)
+    })
 }

+ 40 - 8
packages/alpinejs/src/magics/$id.js

@@ -1,14 +1,46 @@
 import { magic } from '../magics'
 import { closestIdRoot, findAndIncrementId } from '../ids'
+import { interceptClone } from '../clone'
 
-magic('id', el => (name, key = null) => {
-    let root = closestIdRoot(el, name)
+magic('id', (el, { cleanup }) => (name, key = null) => {
+    let cacheKey = `${name}${key ? `-${key}` : ''}`
 
-    let id = root
-        ? root._x_ids[name]
-        : findAndIncrementId(name)
+    return cacheIdByNameOnElement(el, cacheKey, cleanup, () => {
+        let root = closestIdRoot(el, name)
 
-    return key
-        ? `${name}-${id}-${key}`
-        : `${name}-${id}`
+        let id = root
+            ? root._x_ids[name]
+            : findAndIncrementId(name)
+
+        return key
+            ? `${name}-${id}-${key}`
+            : `${name}-${id}`
+    })
+})
+
+interceptClone((from, to) => {
+    // Transfer over existing ID registrations from
+    // the existing dom tree over to the new one
+    // so that there aren't ID mismatches...
+    if (from._x_id) {
+        to._x_id = from._x_id
+    }
 })
+
+function cacheIdByNameOnElement(el, cacheKey, cleanup, callback)
+{
+    if (! el._x_id) el._x_id = {}
+
+    // We only want $id to run once per an element's lifecycle...
+    if (el._x_id[cacheKey]) return el._x_id[cacheKey]
+
+    let output = callback()
+
+    el._x_id[cacheKey] = output
+
+    cleanup(() => {
+        delete el._x_id[cacheKey]
+    })
+
+    return output
+}

+ 10 - 25
packages/alpinejs/src/magics/$watch.js

@@ -1,34 +1,19 @@
 import { magic } from '../magics'
+import { effect, release, watch } from '../reactivity'
 
-magic('watch', (el, { evaluateLater, effect }) => (key, callback) => {
+magic('watch', (el, { evaluateLater, cleanup }) => (key, callback) => {
     let evaluate = evaluateLater(key)
 
-    let firstTime = true
+    let getter = () => {
+        let value
 
-    let oldValue
+        evaluate(i => value = i)
 
-    let effectReference = effect(() => evaluate(value => {
-        // JSON.stringify touches every single property at any level enabling deep watching
-        JSON.stringify(value)
+        return value
+    }
 
-        if (! firstTime) {
-            // We have to queue this watcher as a microtask so that
-            // the watcher doesn't pick up its own dependencies.
-            queueMicrotask(() => {
-                callback(value, oldValue)
+    let unwatch = watch(getter, callback)
 
-                oldValue = value
-            })
-        } else {
-            oldValue = value
-        }
-
-        firstTime = false
-    }))
-
-    // We want to remove this effect from the list of effects
-    // stored on an element. Livewire uses that list to
-    // "re-run" Alpine effects after a page load. A "watcher"
-    // shuldn't be re-run like that. It will cause infinite loops.
-    el._x_effects.delete(effectReference)
+    cleanup(unwatch)
 })
+

+ 1 - 1
packages/alpinejs/src/magics/index.js

@@ -16,5 +16,5 @@ warnMissingPluginMagic('Focus', 'focus', 'focus')
 warnMissingPluginMagic('Persist', 'persist', 'persist')
 
 function warnMissingPluginMagic(name, magicName, slug) {
-    magic(magicName, (el) => warn(`You can't use [$${directiveName}] without first installing the "${name}" plugin here: https://alpinejs.dev/plugins/${slug}`, el))
+    magic(magicName, (el) => warn(`You can't use [$${magicName}] without first installing the "${name}" plugin here: https://alpinejs.dev/plugins/${slug}`, el))
 }

+ 24 - 21
packages/alpinejs/src/mutation.js

@@ -1,3 +1,5 @@
+import { destroyTree } from "./lifecycle"
+
 let onAttributeAddeds = []
 let onElRemoveds = []
 let onElAddeds = []
@@ -39,6 +41,12 @@ export function cleanupAttributes(el, names) {
     })
 }
 
+export function cleanupElement(el) {
+    if (el._x_cleanups) {
+        while (el._x_cleanups.length) el._x_cleanups.pop()()
+    }
+}
+
 let observer = new MutationObserver(onMutate)
 
 let currentlyObserving = false
@@ -57,27 +65,24 @@ export function stopObservingMutations() {
     currentlyObserving = false
 }
 
-let recordQueue = []
-let willProcessRecordQueue = false
+let queuedMutations = []
 
 export function flushObserver() {
-    recordQueue = recordQueue.concat(observer.takeRecords())
+    let records = observer.takeRecords()
 
-    if (recordQueue.length && ! willProcessRecordQueue) {
-        willProcessRecordQueue = true
+    queuedMutations.push(() => records.length > 0 && onMutate(records))
 
-        queueMicrotask(() => {
-            processRecordQueue()
+    let queueLengthWhenTriggered = queuedMutations.length
 
-            willProcessRecordQueue = false
-        })
-    }
-}
-
-function processRecordQueue() {
-     onMutate(recordQueue)
-
-     recordQueue.length = 0
+    queueMicrotask(() => {
+        // If these two lengths match, then we KNOW that this is the LAST
+        // flush in the current event loop. This way, we can process
+        // all mutations in one batch at the end of everything...
+        if (queuedMutations.length === queueLengthWhenTriggered) {
+            // Now Alpine can process all the mutations...
+            while (queuedMutations.length > 0) queuedMutations.shift()()
+        }
+    })
 }
 
 export function mutateDom(callback) {
@@ -147,11 +152,11 @@ function onMutate(mutations) {
             // New attribute.
             if (el.hasAttribute(name) && oldValue === null) {
                 add()
-            // Changed atttribute.
+            // Changed attribute.
             } else if (el.hasAttribute(name)) {
                 remove()
                 add()
-            // Removed atttribute.
+            // Removed attribute.
             } else {
                 remove()
             }
@@ -173,9 +178,7 @@ function onMutate(mutations) {
 
         onElRemoveds.forEach(i => i(node))
 
-        if (node._x_cleanups) {
-            while (node._x_cleanups.length) node._x_cleanups.pop()()
-        }
+        destroyTree(node)
     }
 
     // Mutations are bundled together by the browser but sometimes

+ 29 - 0
packages/alpinejs/src/reactivity.js

@@ -56,6 +56,35 @@ export function elementBoundEffect(el) {
     return [wrappedEffect, () => { cleanup() }]
 }
 
+export function watch(getter, callback) {
+    let firstTime = true
+
+    let oldValue
+
+    let effectReference = effect(() => {
+        let value = getter()
+
+        // JSON.stringify touches every single property at any level enabling deep watching
+        JSON.stringify(value)
+
+        if (! firstTime) {
+            // We have to queue this watcher as a microtask so that
+            // the watcher doesn't pick up its own dependencies.
+            queueMicrotask(() => {
+                callback(value, oldValue)
+
+                oldValue = value
+            })
+        } else {
+            oldValue = value
+        }
+
+        firstTime = false
+    })
+
+    return () => release(effectReference)
+}
+
 export {
     release,
     reactive,

+ 50 - 61
packages/alpinejs/src/scope.js

@@ -33,65 +33,54 @@ export function closestDataProxy(el) {
     return mergeProxies(closestDataStack(el))
 }
 
-export function mergeProxies(objects) {
-    let thisProxy = new Proxy({}, {
-        ownKeys: () => {
-            return Array.from(new Set(objects.flatMap(i => Object.keys(i))))
-        },
-
-        has: (target, name) => {
-            return objects.some(obj => obj.hasOwnProperty(name))
-        },
-
-        get: (target, name) => {
-            return (objects.find(obj => {
-                if (obj.hasOwnProperty(name)) {
-                    let descriptor = Object.getOwnPropertyDescriptor(obj, name)
-
-                    // If we already bound this getter, don't rebind.
-                    if ((descriptor.get && descriptor.get._x_alreadyBound) || (descriptor.set && descriptor.set._x_alreadyBound)) {
-                        return true
-                    }
-
-                    // Properly bind getters and setters to this wrapper Proxy.
-                    if ((descriptor.get || descriptor.set) && descriptor.enumerable) {
-                        // Only bind user-defined getters, not our magic properties.
-                        let getter = descriptor.get
-                        let setter = descriptor.set
-                        let property = descriptor
-
-                        getter = getter && getter.bind(thisProxy)
-                        setter = setter && setter.bind(thisProxy)
-
-                        if (getter) getter._x_alreadyBound = true
-                        if (setter) setter._x_alreadyBound = true
-
-                        Object.defineProperty(obj, name, {
-                            ...property,
-                            get: getter,
-                            set: setter,
-                        })
-                    }
-
-                    return true
-                }
-
-                return false
-            }) || {})[name]
-        },
-
-        set: (target, name, value) => {
-            let closestObjectWithKey = objects.find(obj => obj.hasOwnProperty(name))
-
-            if (closestObjectWithKey) {
-                closestObjectWithKey[name] = value
-            } else {
-                objects[objects.length - 1][name] = value
-            }
-
-            return true
-        },
-    })
-
-    return thisProxy
+export function mergeProxies (objects) {
+    return new Proxy({ objects }, mergeProxyTrap);
+}
+
+let mergeProxyTrap = {
+    ownKeys({ objects }) {
+        return Array.from(
+            new Set(objects.flatMap((i) => Object.keys(i)))
+        )
+    },
+
+    has({ objects }, name) {
+        if (name == Symbol.unscopables) return false;
+
+        return objects.some((obj) =>
+            Object.prototype.hasOwnProperty.call(obj, name)
+        );
+    },
+
+    get({ objects }, name, thisProxy) {
+        if (name == "toJSON") return collapseProxies
+
+        return Reflect.get(
+            objects.find((obj) =>
+                Object.prototype.hasOwnProperty.call(obj, name)
+            ) || {},
+            name,
+            thisProxy
+        )
+    },
+
+    set({ objects }, name, value, thisProxy) {
+        const target = objects.find((obj) =>
+                Object.prototype.hasOwnProperty.call(obj, name)
+            ) || objects[objects.length - 1];
+        const descriptor = Object.getOwnPropertyDescriptor(target, name);
+        if (descriptor?.set && descriptor?.get)
+            return Reflect.set(target, name, value, thisProxy);
+        return Reflect.set(target, name, value);
+    },
+}
+
+function collapseProxies() {
+    let keys = Reflect.ownKeys(this)
+
+    return keys.reduce((acc, key) => {
+        acc[key] = Reflect.get(this, key)
+
+        return acc;
+    }, {})
 }

+ 43 - 5
packages/alpinejs/src/utils/bind.js

@@ -1,3 +1,4 @@
+import { dontAutoEvaluateFunctions, evaluate } from '../evaluator'
 import { reactive } from '../reactivity'
 import { setClasses } from './classes'
 import { setStyles } from './styles'
@@ -22,9 +23,9 @@ export default function bind(el, name, value, modifiers = []) {
         case 'class':
             bindClasses(el, value)
             break;
-        
+
         // 'selected' and 'checked' are special attributes that aren't necessarily
-        // synced with their corresponding properties when updated, so both the 
+        // synced with their corresponding properties when updated, so both the
         // attribute and property need to be updated when bound.
         case 'selected':
         case 'checked':
@@ -48,7 +49,11 @@ function bindInputValue(el, value) {
 
         // @todo: yuck
         if (window.fromModel) {
-            el.checked = checkedAttrLooseCompare(el.value, value)
+            if (typeof value === 'boolean') {
+                el.checked = safeParseBoolean(el.value) === value
+            } else {
+                el.checked = checkedAttrLooseCompare(el.value, value)
+            }
         }
     } else if (el.type === 'checkbox') {
         // If we are explicitly binding a string to the :value, set the string,
@@ -56,7 +61,7 @@ function bindInputValue(el, value) {
         // automatically.
         if (Number.isInteger(value)) {
             el.value = value
-        } else if (! Number.isInteger(value) && ! Array.isArray(value) && typeof value !== 'boolean' && ! [null, undefined].includes(value)) {
+        } else if (! Array.isArray(value) && typeof value !== 'boolean' && ! [null, undefined].includes(value)) {
             el.value = String(value)
         } else {
             if (Array.isArray(value)) {
@@ -70,7 +75,7 @@ function bindInputValue(el, value) {
     } else {
         if (el.value === value) return
 
-        el.value = value
+        el.value = value === undefined ? '' : value
     }
 }
 
@@ -129,6 +134,18 @@ function checkedAttrLooseCompare(valueA, valueB) {
     return valueA == valueB
 }
 
+export function safeParseBoolean(rawValue) {
+    if ([1, '1', 'true', 'on', 'yes', true].includes(rawValue)) {
+        return true
+    }
+
+    if ([0, '0', 'false', 'off', 'no', false].includes(rawValue)) {
+        return false
+    }
+
+    return rawValue ? Boolean(rawValue) : null
+}
+
 function isBooleanAttr(attrName) {
     // As per HTML spec table https://html.spec.whatwg.org/multipage/indices.html#attributes-3:boolean-attribute
     // Array roughly ordered by estimated usage
@@ -151,6 +168,27 @@ export function getBinding(el, name, fallback) {
     // First let's get it out of Alpine bound data.
     if (el._x_bindings && el._x_bindings[name] !== undefined) return el._x_bindings[name]
 
+    return getAttributeBinding(el, name, fallback)
+}
+
+export function extractProp(el, name, fallback, extract = true) {
+    // First let's get it out of Alpine bound data.
+    if (el._x_bindings && el._x_bindings[name] !== undefined) return el._x_bindings[name]
+
+    if (el._x_inlineBindings && el._x_inlineBindings[name] !== undefined) {
+        let binding = el._x_inlineBindings[name]
+
+        binding.extract = extract
+
+        return dontAutoEvaluateFunctions(() => {
+            return evaluate(el, binding.expression)
+        })
+    }
+
+    return getAttributeBinding(el, name, fallback)
+}
+
+function getAttributeBinding(el, name, fallback) {
     // If not, we'll return the literal attribute.
     let attr = el.getAttribute(name)
 

+ 4 - 2
packages/alpinejs/src/utils/error.js

@@ -6,8 +6,10 @@ export function tryCatch(el, expression, callback, ...args) {
     }
 }
 
-export function handleError(error, el, expression = undefined) {
-    Object.assign( error, { el, expression } )
+export function handleError(error , el, expression = undefined) {
+    error = Object.assign( 
+        error ?? { message: 'No error message given.' }, 
+        { el, expression } )
 
     console.warn(`Alpine Expression Error: ${error.message}\n\n${ expression ? 'Expression: \"' + expression + '\"\n\n' : '' }`, el)
 

+ 0 - 17
packages/alpinejs/src/utils/walk.js

@@ -19,20 +19,3 @@ export function walk(el, callback) {
         node = node.nextElementSibling
     }
 }
-// export function walk(el, callback) {
-//     if (el instanceof ShadowRoot || el instanceof DocumentFragment) {
-//         Array.from(el.children).forEach(el => walk(el, callback))
-
-//         return
-//     }
-
-//     callback(el, () => {
-//         let node = el.firstElementChild
-
-//         while (node) {
-//             walk(node, callback)
-
-//             node = node.nextElementSibling
-//         }
-//     })
-// }

+ 5 - 0
packages/anchor/builds/cdn.js

@@ -0,0 +1,5 @@
+import anchor from '../src/index.js'
+
+document.addEventListener('alpine:init', () => {
+    window.Alpine.plugin(anchor)
+})

+ 3 - 0
packages/anchor/builds/module.js

@@ -0,0 +1,3 @@
+import anchor from '../src/index.js'
+
+export default anchor

+ 17 - 0
packages/anchor/package.json

@@ -0,0 +1,17 @@
+{
+    "name": "@alpinejs/anchor",
+    "version": "3.13.3",
+    "description": "Anchor an element's position relative to another",
+    "homepage": "https://alpinejs.dev/plugins/anchor",
+    "repository": {
+        "type": "git",
+        "url": "https://github.com/alpinejs/alpine.git",
+        "directory": "packages/anchor"
+    },
+    "author": "Caleb Porzio",
+    "license": "MIT",
+    "main": "dist/module.cjs.js",
+    "module": "dist/module.esm.js",
+    "unpkg": "dist/cdn.min.js",
+    "dependencies": {}
+}

+ 77 - 0
packages/anchor/src/index.js

@@ -0,0 +1,77 @@
+import { computePosition, autoUpdate, flip, offset, shift } from '@floating-ui/dom'
+
+export default function (Alpine) {
+    Alpine.magic('anchor', el => {
+        if (! el._x_anchor) throw 'Alpine: No x-anchor directive found on element using $anchor...'
+
+        return el._x_anchor
+    })
+
+    Alpine.interceptClone((from, to) => {
+        if (from && from._x_anchor && ! to._x_anchor) {
+            to._x_anchor = from._x_anchor
+        }
+    })
+
+    Alpine.directive('anchor', Alpine.skipDuringClone((el, { expression, modifiers, value }, { cleanup, evaluate }) => {
+        let { placement, offsetValue, unstyled } = getOptions(modifiers)
+
+        el._x_anchor = Alpine.reactive({ x: 0, y: 0 })
+
+        let reference = evaluate(expression)
+
+        if (! reference) throw 'Alpine: no element provided to x-anchor...'
+
+        let compute = () => {
+            let previousValue
+
+            computePosition(reference, el, {
+                placement,
+                middleware: [flip(), shift({padding: 5}), offset(offsetValue)],
+            }).then(({ x, y }) => {
+                unstyled || setStyles(el, x, y)
+
+                // Only trigger Alpine reactivity when the value actually changes...
+                if (JSON.stringify({ x, y }) !== previousValue) {
+                    el._x_anchor.x = x
+                    el._x_anchor.y = y
+                }
+
+                previousValue = JSON.stringify({ x, y })
+            })
+        }
+
+        let release = autoUpdate(reference, el, () => compute())
+
+        cleanup(() => release())
+    },
+
+    // When cloning (or "morphing"), we will graft the style and position data from the live tree...
+    (el, { expression, modifiers, value }, { cleanup, evaluate }) => {
+        let { placement, offsetValue, unstyled } = getOptions(modifiers)
+
+        if (el._x_anchor) {
+            unstyled || setStyles(el, el._x_anchor.x, el._x_anchor.y)
+        }
+    }))
+}
+
+function setStyles(el, x, y) {
+    Object.assign(el.style, {
+        left: x+'px', top: y+'px', position: 'absolute',
+    })
+}
+
+function getOptions(modifiers) {
+    let positions = ['top', 'top-start', 'top-end', 'right', 'right-start', 'right-end', 'bottom', 'bottom-start', 'bottom-end', 'left', 'left-start', 'left-end']
+    let placement = positions.find(i => modifiers.includes(i))
+    let offsetValue = 0
+    if (modifiers.includes('offset')) {
+        let idx = modifiers.findIndex(i => i === 'offset')
+
+        offsetValue = modifiers[idx + 1] !== undefined ? Number(modifiers[idx + 1]) : offsetValue
+    }
+    let unstyled = modifiers.includes('no-style')
+
+    return { placement, offsetValue, unstyled }
+}

+ 1 - 1
packages/collapse/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/collapse",
-    "version": "3.12.1",
+    "version": "3.13.3",
     "description": "Collapse and expand elements with robust animations",
     "homepage": "https://alpinejs.dev/plugins/collapse",
     "repository": {

+ 3 - 3
packages/csp/package.json

@@ -1,12 +1,12 @@
 {
     "name": "@alpinejs/csp",
-    "version": "3.0.0-alpha.0",
-    "description": "A CSP compatible build of Alpine",
+    "version": "3.13.3",
+    "description": "A CSP-friendly build of AlpineJS",
     "author": "Caleb Porzio",
     "license": "MIT",
     "main": "dist/module.cjs.js",
     "module": "dist/module.esm.js",
     "dependencies": {
-        "@vue/reactivity": "^3.0.2"
+        "@vue/reactivity": "~3.1.1"
     }
 }

+ 50 - 0
packages/csp/src/evaluator.js

@@ -0,0 +1,50 @@
+import { generateEvaluatorFromFunction, runIfTypeOfFunction } from 'alpinejs/src/evaluator'
+import { closestDataStack, mergeProxies } from 'alpinejs/src/scope'
+import { tryCatch } from 'alpinejs/src/utils/error'
+import { injectMagics } from 'alpinejs/src/magics'
+
+export function cspEvaluator(el, expression) {
+    let dataStack = generateDataStack(el)
+
+    // Return if the provided expression is already a function...
+    if (typeof expression === 'function') {
+        return generateEvaluatorFromFunction(dataStack, expression)
+    }
+
+    let evaluator = generateEvaluator(el, expression, dataStack)
+
+    return tryCatch.bind(null, el, expression, evaluator)
+}
+
+function generateDataStack(el) {
+    let overriddenMagics = {}
+
+    injectMagics(overriddenMagics, el)
+
+    return [overriddenMagics, ...closestDataStack(el)]
+}
+
+function generateEvaluator(el, expression, dataStack) {
+    return (receiver = () => {}, { scope = {}, params = [] } = {}) => {
+        let completeScope = mergeProxies([scope, ...dataStack])
+
+        if (completeScope[expression] === undefined) {
+            throwExpressionError(el, expression)
+        }
+
+        runIfTypeOfFunction(receiver, completeScope[expression], completeScope, params)
+    }
+}
+
+function throwExpressionError(el, expression) {
+    console.warn(
+`Alpine Error: Alpine is unable to interpret the following expression using the CSP-friendly build:
+
+"${expression}"
+
+Read more about the Alpine's CSP-friendly build restrictions here: https://alpinejs.dev/advanced/csp
+
+`,
+el
+    )
+}

+ 28 - 29
packages/csp/src/index.js

@@ -1,38 +1,37 @@
+/**
+ * Alpine CSP Build.
+ *
+ * Alpine allows you to use JavaScript directly inside your HTML. This is an
+ * incredibly powerful features. However, it violates the "unsafe-eval"
+ * Content Security Policy. This alternate Alpine build provides a
+ * more constrained API for Alpine that is also CSP-friendly...
+ */
 import Alpine from 'alpinejs/src/alpine'
 
-Alpine.setEvaluator(cspCompliantEvaluator)
-
+/**
+ * _______________________________________________________
+ * The Evaluator
+ * -------------------------------------------------------
+ *
+ * By default, Alpine's evaluator "eval"-like utilties to
+ * interpret strings as runtime JS. We're going to use
+ * a more CSP-friendly evaluator for this instead.
+ */
+import { cspEvaluator } from './evaluator'
+
+Alpine.setEvaluator(cspEvaluator)
+
+/**
+ * The rest of this file bootstraps Alpine the way it is
+ * normally bootstrapped in the default build. We will
+ * set and define it's directives, magics, etc...
+ */
 import { reactive, effect, stop, toRaw } from '@vue/reactivity'
+
 Alpine.setReactivityEngine({ reactive, effect, release: stop, raw: toRaw })
 
 import 'alpinejs/src/magics/index'
-import 'alpinejs/src/directives/index'
-
-import { closestDataStack, mergeProxies } from 'alpinejs/src/scope'
-import { injectMagics } from 'alpinejs/src/magics'
-import { generateEvaluatorFromFunction, runIfTypeOfFunction } from 'alpinejs/src/evaluator'
-import { tryCatch } from 'alpinejs/src/utils/error'
-
-function cspCompliantEvaluator(el, expression) {
-    let overriddenMagics = {}
-
-    injectMagics(overriddenMagics, el)
 
-    let dataStack = [overriddenMagics, ...closestDataStack(el)]
-
-    if (typeof expression === 'function') {
-        return generateEvaluatorFromFunction(dataStack, expression)
-    }
-
-    let evaluator = (receiver = () => {}, { scope = {}, params = [] } = {}) => {
-        let completeScope = mergeProxies([scope, ...dataStack])
-
-        if (completeScope[expression] !== undefined) {
-            runIfTypeOfFunction(receiver, completeScope[expression], completeScope, params)
-        }
-   }
-
-    return tryCatch.bind(null, el, expression, evaluator)
-}
+import 'alpinejs/src/directives/index'
 
 export default Alpine

+ 1 - 1
packages/docs/package.json

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

+ 64 - 16
packages/docs/src/en/advanced/csp.md

@@ -1,42 +1,87 @@
 ---
-order: 5
+order: 1
 title: CSP
 ---
 
-# CSP (Content-Security Policy)
+# CSP (Content-Security Policy) Build
 
-In order for Alpine to be able to execute plain strings from HTML attributes as JavaScript expressions, for example `x-on:click="console.log()"`, it needs to rely on utilities that violate the "unsafe-eval" content security policy.
+In order for Alpine to be able to execute plain strings from HTML attributes as JavaScript expressions, for example `x-on:click="console.log()"`, it needs to rely on utilities that violate the "unsafe-eval" [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) that some applications may enforce for security purposes.
 
 > Under the hood, Alpine doesn't actually use eval() itself because it's slow and problematic. Instead it uses Function declarations, which are much better, but still violate "unsafe-eval".
 
-In order to accommodate environments where this CSP is necessary, Alpine will offer an alternate build that doesn't violate "unsafe-eval", but has a more restrictive syntax.
+In order to accommodate environments where this CSP is necessary, Alpine offer's an alternate build that doesn't violate "unsafe-eval", but has a more restrictive syntax.
 
 <a name="installation"></a>
 ## Installation
 
-The CSP build hasn’t been officially released yet. In the meantime, you may [build it from source](https://github.com/alpinejs/alpine/tree/main/packages/csp). Once released, like all Alpine extensions, you will be able to include this either via `<script>` tag or module import:
+You can use this build by either including it from a `<script>` tag or installing it via NPM:
 
-<a name="script-tag"></a>
-### Script tag
+### Via CDN
+
+You can include this build's CDN as a `<script>` tag just like you would normally with standard Alpine build:
 
 ```alpine
-<html>
-    <script src="alpinejs/alpinejs-csp/cdn.js" defer></script>
-</html>
+<!-- Alpine's CSP-friendly Core -->
+<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/csp@3.x.x/dist/cdn.min.js"></script>
+```
+
+### Via NPM
+
+You can alternatively install this build from NPM for use inside your bundle like so:
+
+```shell
+npm install @alpinejs/csp
 ```
 
-<a name="module-import"></a>
-### Module import
+Then initialize it from your bundle:
 
 ```js
 import Alpine from '@alpinejs/csp'
 
 window.Alpine = Alpine
-window.Alpine.start()
+
+Alpine.start()
 ```
 
-<a name="restrictions"></a>
-## Restrictions
+<a name="basic-example"></a>
+## Basic Example
+
+To provide a glimpse of how using the CSP build might feel, here is a copy-pastable HTML file with a working counter component using a common CSP setup:
+
+```alpine
+<html>
+    <head>
+        <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'nonce-a23gbfz9e'">
+
+        <script defer nonce="a23gbfz9e" src="https://cdn.jsdelivr.net/npm/@alpinejs/csp@3.x.x/dist/cdn.min.js"></script>
+    </head>
+
+    <body>
+        <div x-data="counter">
+            <button x-on:click="increment"></button>
+
+            <span x-text="count"></span>
+        </div>
+
+        <script nonce="a23gbfz9e">
+            document.addEventListener('alpine:init', () => {
+                Alpine.data('counter', () => {
+                    return {
+                        count: 1,
+
+                        increment() {
+                            this.count++;
+                        },
+                    }
+                })
+            })
+        </script>
+    </body>
+</html>
+```
+
+<a name="api-restrictions"></a>
+## API Restrictions
 
 Since Alpine can no longer interpret strings as plain JavaScript, it has to parse and construct JavaScript functions from them manually.
 
@@ -63,10 +108,13 @@ However, breaking out the expressions into external APIs, the following is valid
     <span x-text="count"></span>
 </div>
 ```
+
 ```js
 Alpine.data('counter', () => ({
     count: 1,
 
-    increment() { this.count++ }
+    increment() {
+        this.count++
+    },
 }))
 ```

+ 2 - 2
packages/docs/src/en/advanced/extending.md

@@ -1,5 +1,5 @@
 ---
-order: 2
+order: 3
 title: Extending
 ---
 
@@ -229,7 +229,7 @@ Now if the directive is removed from this element or the element is removed itse
 
 By default, any new directive will run after the majority of the standard ones (with the exception of `x-teleport`). This is usually acceptable but some times you might need to run your custom directive before another specific one.
 This can be achieved by chaining the `.before() function to `Alpine.directive()` and specifying which directive needs to run after your custom one.
- 
+
 ```js
 Alpine.directive('foo', (el, { value, modifiers, expression }) => {
     Alpine.addScopeToNode(el, {foo: 'bar'})

+ 1 - 1
packages/docs/src/en/advanced/reactivity.md

@@ -1,5 +1,5 @@
 ---
-order: 1
+order: 2
 title: Reactivity
 ---
 

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

@@ -25,6 +25,30 @@ Alpine's `x-for` directive allows you to create DOM elements by iterating throug
 </div>
 <!-- END_VERBATIM -->
 
+You may also pass objects to `x-for`.
+
+```alpine
+<ul x-data="{ car: { make: 'Jeep', model: 'Grand Cherokee', color: 'Black' } }">
+    <template x-for="(value, index) in car">
+        <li>
+            <span x-text="index"></span>: <span x-text="value"></span>
+        </li>
+    </template>
+</ul>
+```
+
+<!-- START_VERBATIM -->
+<div class="demo">
+    <ul x-data="{ car: { make: 'Jeep', model: 'Grand Cherokee', color: 'Black' } }">
+        <template x-for="(value, index) in car">
+            <li>
+                <span x-text="index"></span>: <span x-text="value"></span>
+            </li>
+        </template>
+    </ul>
+</div>
+<!-- END_VERBATIM -->
+
 There are two rules worth noting about `x-for`:
 
 >`x-for` MUST be declared on a `<template>` element

+ 15 - 0
packages/docs/src/en/directives/init.md

@@ -72,3 +72,18 @@ Alpine.data('dropdown', () => ({
     },
 }))
 ```
+
+If you have both an `x-data` object containing an `init()` method and an `x-init` directive, the `x-data` method will be called before the directive.
+
+```alpine
+<div
+    x-data="{
+        init() {
+            console.log('I am called first')
+        }
+    }"
+    x-init="console.log('I am called second')"
+    >
+    ...
+</div>
+```

+ 34 - 0
packages/docs/src/en/directives/model.md

@@ -62,6 +62,7 @@ Now when the `<button>` is clicked, the input element's value will instantly be
 * `<input type="checkbox">`
 * `<input type="radio">`
 * `<select>`
+* `<input type="range">`
 
 <a name="text-inputs"></a>
 ## Text inputs
@@ -282,6 +283,26 @@ Color: <span x-text="color"></span>
 </div>
 <!-- END_VERBATIM -->
 
+<a name="range-inputs"></a>
+## Range inputs
+
+```alpine
+<input type="range" x-model="range" min="0" max="1" step="0.1">
+
+<span x-text="range"></span>
+```
+
+<!-- START_VERBATIM -->
+<div class="demo">
+    <div x-data="{ range: 0.5 }">
+        <input type="range" x-model="range" min="0" max="1" step="0.1">
+
+        <div class="pt-4" x-text="range"></div>
+    </div>
+</div>
+<!-- END_VERBATIM -->
+
+
 <a name="modifiers"></a>
 ## Modifiers
 
@@ -307,6 +328,19 @@ By default, any data stored in a property via `x-model` is stored as a string. T
 <span x-text="typeof age"></span>
 ```
 
+<a name="boolean"></a>
+### `.boolean`
+
+By default, any data stored in a property via `x-model` is stored as a string. To force Alpine to store the value as a JavaScript boolean, add the `.boolean` modifier. Both integers (1/0) and strings (true/false) are valid boolean values.
+
+```alpine
+<select x-model.boolean="isActive">
+    <option value="true">Yes</option>
+    <option value="false">No</option>
+</select>
+<span x-text="typeof isActive"></span>
+```
+
 <a name="debounce"></a>
 ### `.debounce`
 

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

@@ -303,7 +303,7 @@ If you are listening for touch events, it's important to add `.passive` to your
 
 Add this modifier if you want to execute this listener in the event's capturing phase, e.g. before the event bubbles from the target element up the DOM.
 
-```
+```alpine
 <div @click.capture="console.log('I will log first')">
     <button @click="console.log('I will log second')"></button>
 </div>

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

@@ -33,7 +33,7 @@ This is by far the simplest way to get started with Alpine. Include the followin
 Notice the `@3.x.x` in the provided CDN link. This will pull the latest version of Alpine version 3. For stability in production, it's recommended that you hardcode the latest version in the CDN link.
 
 ```alpine
-<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.12.1/dist/cdn.min.js"></script>
+<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"></script>
 ```
 
 That's it! Alpine is now available for use inside your page.
@@ -61,8 +61,9 @@ Alpine.start()
 
 > The `window.Alpine = Alpine` bit is optional, but is nice to have for freedom and flexibility. Like when tinkering with Alpine from the devtools for example.
 
-
 > If you imported Alpine into a bundle, you have to make sure you are registering any extension code IN BETWEEN when you import the `Alpine` global object, and when you initialize Alpine by calling `Alpine.start()`.
 
+> Ensure that `Alpine.start()` is only called once per page. Calling it more than once will result in multiple "instances" of Alpine running at the same time.
+
 
 [→ Read more about extending Alpine](/advanced/extending)

+ 38 - 1
packages/docs/src/en/globals/alpine-data.md

@@ -37,7 +37,7 @@ As you can see we've extracted the properties and methods we would usually defin
 If you've chosen to use a build step for your Alpine code, you should register your components in the following way:
 
 ```js
-import Alpine from `alpinejs`
+import Alpine from 'alpinejs'
 import dropdown from './dropdown.js'
 
 Alpine.data('dropdown', dropdown)
@@ -87,6 +87,43 @@ Alpine.data('dropdown', () => ({
 }))
 ```
 
+<a name="destroy-functions"></a>
+## Destroy functions
+
+If your component contains a `destroy()` method, Alpine will automatically execute it before cleaning up the component.
+
+A primary example for this is when registering an event handler with another library or a browser API that isn't available through Alpine.
+See the following example code on how to use the `destroy()` method to clean up such a handler.
+
+```js
+Alpine.data('timer', () => ({
+    timer: null,
+    counter: 0,
+    init() {
+      // Register an event handler that references the component instance
+      this.timer = setInterval(() => {
+        console.log('Increased counter to', ++this.counter);
+      }, 1000);
+    },
+    destroy() {
+        // Detach the handler, avoiding memory and side-effect leakage
+        clearInterval(this.timer);
+    },
+}))
+```
+
+An example where a component is destroyed is when using one inside an `x-if`:
+
+```html
+<span x-data="{ enabled: false }">
+    <button @click.prevent="enabled = !enabled">Toggle</button>
+
+    <template x-if="enabled">
+        <span x-data="timer" x-text="counter"></span>
+    </template>
+</span>
+```
+
 <a name="using-magic-properties"></a>
 ## Using magic properties
 

+ 15 - 0
packages/docs/src/en/magics/refs.md

@@ -25,3 +25,18 @@ title: refs
 <!-- END_VERBATIM -->
 
 Now, when the `<button>` is pressed, the `<span>` will be removed.
+
+<a name="limitations"></a>
+### Limitations
+
+In V2 it was possible to bind `$refs` to elements dynamically, like seen below:
+
+```alpine
+<template x-for="item in items" :key="item.id" >
+    <div :x-ref="item.name">
+    some content ...
+    </div>
+</template>
+```
+
+However, in V3, `$refs` can only be accessed for elements that are created statically. So for the example above: if you were expecting the value of `item.name` inside of `$refs` to be something like *Batteries*, you should be aware that `$refs` will actually contain the literal string `'item.name'` and not *Batteries*.

+ 1 - 1
packages/docs/src/en/magics/watch.md

@@ -39,7 +39,7 @@ When the `<button>` is pressed, `foo.bar` will be set to "bob", and "bob" will b
 <a name="deep-watching"></a>
 ### Deep watching
 
-`$watch` will automatically watches from changes at any level but you should keep in mind that, when a change is detected, the watcher will return the value of the observed property, not the value of the subproperty that has changed.
+`$watch` automatically watches from changes at any level but you should keep in mind that, when a change is detected, the watcher will return the value of the observed property, not the value of the subproperty that has changed.
 
 ```alpine
 <div x-data="{ foo: { bar: 'baz' }}" x-init="$watch('foo', (value, oldValue) => console.log(value, oldValue))">

+ 213 - 0
packages/docs/src/en/plugins/anchor.md

@@ -0,0 +1,213 @@
+---
+order: 5
+title: Anchor
+description: Anchor an element's positioning to another element on the pageg
+graph_image: https://alpinejs.dev/social_anchor.jpg
+---
+
+# Anchor Plugin
+
+Alpine's Anchor plugin allows you easily anchor an element's positioning to another element on the page.
+
+This functionality is useful when creating dropdown menus, popovers, dialogs, and tooltips with Alpine.
+
+The "anchoring" functionality used in this plugin is provided by the [Floating UI](https://floating-ui.com/) project.
+
+<a name="installation"></a>
+## Installation
+
+You can use this plugin by either including it from a `<script>` tag or installing it via NPM:
+
+### Via CDN
+
+You can include the CDN build of this plugin as a `<script>` tag, just make sure to include it BEFORE Alpine's core JS file.
+
+```alpine
+<!-- Alpine Plugins -->
+<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/anchor@3.x.x/dist/cdn.min.js"></script>
+
+<!-- Alpine Core -->
+<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
+```
+
+### Via NPM
+
+You can install Anchor from NPM for use inside your bundle like so:
+
+```shell
+npm install @alpinejs/anchor
+```
+
+Then initialize it from your bundle:
+
+```js
+import Alpine from 'alpinejs'
+import anchor from '@alpinejs/anchor'
+
+Alpine.plugin(anchor)
+
+...
+```
+
+<a name="x-anchor"></a>
+## x-anchor
+
+The primary API for using this plugin is the `x-anchor` directive.
+
+To use this plugin, add the `x-anchor` directive to any element and pass it a reference to the element you want to anchor it's position to (often a button on the page).
+
+By default, `x-anchor` will set the the element's CSS to `position: absolute` and the appropriate `top` and `left` values. If the anchored element is normally displayed below the reference element but doesn't have room on the page, it's styling will be adjusted to render above the element.
+
+For example, here's a simple dropdown anchored to the button that toggles it:
+
+```alpine
+<div x-data="{ open: false }">
+    <button x-ref="button" @click="open = ! open">Toggle</button>
+
+    <div x-show="open" x-anchor="$refs.button">
+        Dropdown content
+    </div>
+</div>
+```
+
+<!-- START_VERBATIM -->
+<div x-data="{ open: false }" class="demo overflow-hidden">
+    <div class="flex justify-center">
+        <button x-ref="button" @click="open = ! open">Toggle</button>
+    </div>
+
+    <div x-show="open" x-anchor="$refs.button" class="bg-white rounded p-4 border shadow z-10">
+        Dropdown content
+    </div>
+</div>
+<!-- END_VERBATIM -->
+
+<a name="positioning"></a>
+## Positioning
+
+`x-anchor` allows you to customize the positioning of the anchored element using the following modifiers:
+
+* Bottom: `.bottom`, `.bottom-start`, `.bottom-end`
+* Top: `.top`, `.top-start`, `.top-end`
+* Left: `.left`, `.left-start`, `.left-end`
+* Right: `.right`, `.right-start`, `.right-end`
+
+Here is an example of using `.bottom-start` to position a dropdown below and to the right of the reference element:
+
+```alpine
+<div x-data="{ open: false }">
+    <button x-ref="button" @click="open = ! open">Toggle</button>
+
+    <div x-show="open" x-anchor.bottom-start="$refs.button">
+        Dropdown content
+    </div>
+</div>
+```
+
+<!-- START_VERBATIM -->
+<div x-data="{ open: false }" class="demo overflow-hidden">
+    <div class="flex justify-center">
+        <button x-ref="button" @click="open = ! open">Toggle</button>
+    </div>
+
+    <div x-show="open" x-anchor.bottom-start="$refs.button" class="bg-white rounded p-4 border shadow z-10">
+        Dropdown content
+    </div>
+</div>
+<!-- END_VERBATIM -->
+
+<a name="offset"></a>
+## Offset
+
+You can add an offset to your anchored element using the `.offset.[px value]` modifier like so:
+
+```alpine
+<div x-data="{ open: false }">
+    <button x-ref="button" @click="open = ! open">Toggle</button>
+
+    <div x-show="open" x-anchor.offset.10="$refs.button">
+        Dropdown content
+    </div>
+</div>
+```
+
+<!-- START_VERBATIM -->
+<div x-data="{ open: false }" class="demo overflow-hidden">
+    <div class="flex justify-center">
+        <button x-ref="button" @click="open = ! open">Toggle</button>
+    </div>
+
+    <div x-show="open" x-anchor.offset.10="$refs.button" class="bg-white rounded p-4 border shadow z-10">
+        Dropdown content
+    </div>
+</div>
+<!-- END_VERBATIM -->
+
+<a name="manual-styling"></a>
+## Manual styling
+
+By default, `x-anchor` applies the positioning styles to your element under the hood. If you'd prefer full control over styling, you can pass the `.no-style` modifer and use the `$anchor` magic to access the values inside another Alpine expression.
+
+Below is an example of bypassing `x-anchor`'s internal styling and instead applying the styles yourself using `x-bind:style`:
+
+```alpine
+<div x-data="{ open: false }">
+    <button x-ref="button" @click="open = ! open">Toggle</button>
+
+    <div
+        x-show="open"
+        x-anchor.no-style="$refs.button"
+        x-bind:style="{ position: 'absolute', top: $anchor.y+'px', left: $anchor.x+'px' }"
+    >
+        Dropdown content
+    </div>
+</div>
+```
+
+<!-- START_VERBATIM -->
+<div x-data="{ open: false }" class="demo overflow-hidden">
+    <div class="flex justify-center">
+        <button x-ref="button" @click="open = ! open">Toggle</button>
+    </div>
+
+    <div
+        x-show="open"
+        x-anchor.no-style="$refs.button"
+        x-bind:style="{ position: 'absolute', top: $anchor.y+'px', left: $anchor.x+'px' }"
+        class="bg-white rounded p-4 border shadow z-10"
+    >
+        Dropdown content
+    </div>
+</div>
+<!-- END_VERBATIM -->
+
+<a name="from-id"></a>
+## Anchor to an ID
+
+The examples thus far have all been anchoring to other elements using Alpine refs.
+
+Because `x-anchor` accepts a reference to any DOM element, you can use utilities like `document.getElementById()` to anchor to an element by its `id` attribute:
+
+```alpine
+<div x-data="{ open: false }">
+    <button id="trigger" @click="open = ! open">Toggle</button>
+
+    <div x-show="open" x-anchor="document.getElementById('trigger')">
+        Dropdown content
+    </div>
+</div>
+```
+
+<!-- START_VERBATIM -->
+<div x-data="{ open: false }" class="demo overflow-hidden">
+    <div class="flex justify-center">
+        <button class="trigger" @click="open = ! open">Toggle</button>
+    </div>
+
+
+    <div x-show="open" x-anchor="document.querySelector('.trigger')">
+        Dropdown content
+    </div>
+</div>
+<!-- END_VERBATIM -->
+

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

@@ -1,5 +1,5 @@
 ---
-order: 5
+order: 6
 title: Morph
 description: Morph an element into the provided HTML
 graph_image: https://alpinejs.dev/social_morph.jpg

+ 3 - 2
packages/focus/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/focus",
-    "version": "3.12.1",
+    "version": "3.13.3",
     "description": "Manage focus within a page",
     "homepage": "https://alpinejs.dev/plugins/focus",
     "repository": {
@@ -14,6 +14,7 @@
     "module": "dist/module.esm.js",
     "unpkg": "dist/cdn.min.js",
     "dependencies": {
-        "focus-trap": "^6.6.1"
+        "focus-trap": "^6.9.4",
+        "tabbable": "^5.3.3"
     }
 }

+ 5 - 4
packages/focus/src/index.js

@@ -134,12 +134,13 @@ export default function (Alpine) {
 
                 // Start trapping.
                 if (value && ! oldValue) {
-                    setTimeout(() => {
-                        if (modifiers.includes('inert')) undoInert = setInert(el)
-                        if (modifiers.includes('noscroll')) undoDisableScrolling = disableScrolling()
+                    if (modifiers.includes('noscroll')) undoDisableScrolling = disableScrolling()
+                    if (modifiers.includes('inert')) undoInert = setInert(el)
 
+                    // Activate the trap after a generous tick. (Needed to play nice with transitions...)
+                    setTimeout(() => {
                         trap.activate()
-                    });
+                    }, 15)
                 }
 
                 // Stop trapping.

+ 2 - 0
packages/history/builds/module.js

@@ -1,3 +1,5 @@
 import history from '../src/index.js'
+import { track } from '../src/index.js'
 
 export default history
+export { track }

+ 1 - 76
packages/history/src/index.js

@@ -1,76 +1 @@
-export default function history(Alpine) {
-    Alpine.magic('queryString', (el, { interceptor }) =>  {
-        let alias
-
-        return interceptor((initialValue, getter, setter, path, key) => {
-            let pause = false
-            let queryKey = alias || path
-
-            let value = initialValue
-            let url = new URL(window.location.href)
-
-            if (url.searchParams.has(queryKey)) {
-                value = url.searchParams.get(queryKey)
-            }
-
-            setter(value)
-
-            let object = { value }
-
-            url.searchParams.set(queryKey, value)
-
-            replace(url.toString(), path, object)
-
-            window.addEventListener('popstate', (e) => {
-                if (! e.state) return
-                if (! e.state.alpine) return
-
-                Object.entries(e.state.alpine).forEach(([newKey, { value }]) => {
-                    if (newKey !== key) return
-
-                    pause = true
-
-                    Alpine.disableEffectScheduling(() => {
-                        setter(value)
-                    })
-
-                    pause = false
-                })
-            })
-
-            Alpine.effect(() => {
-                let value = getter()
-
-                if (pause) return
-
-                let object = { value }
-
-                let url = new URL(window.location.href)
-
-                url.searchParams.set(queryKey, value)
-
-                push(url.toString(), path, object)
-            })
-
-            return value
-        }, func => {
-            func.as = key => { alias = key; return func }
-        })
-    })
-}
-
-function replace(url, key, object) {
-    let state = window.history.state || {}
-
-    if (! state.alpine) state.alpine = {}
-
-    state.alpine[key] = object
-
-    window.history.replaceState(state, '', url)
-}
-
-function push(url, key, object) {
-    let state = { alpine: {...window.history.state.alpine, ...{[key]: object}} }
-
-    window.history.pushState(state, '', url)
-}
+// This plugin has been moved into the livewire/livewire repository until it's more stable and ready to tag.

+ 0 - 36
packages/history/src/url.js

@@ -1,36 +0,0 @@
-
-export function hasQueryParam(param) {
-    let queryParams = new URLSearchParams(window.location.search);
-
-    return queryParams.has(param)
-}
-
-export function getQueryParam(param) {
-    let queryParams = new URLSearchParams(window.location.search);
-
-    return queryParams.get(param)
-}
-
-export function setQueryParam(param, value) {
-    let queryParams = new URLSearchParams(window.location.search);
-
-    queryParams.set(param, value)
-
-    let url = urlFromQueryParams(queryParams)
-
-    history.replaceState(history.state, '', url)
-}
-
-function urlFromParams(params = {}) {
-    let queryParams = new URLSearchParams(window.location.search);
-
-    Object.entries(params).forEach(([key, value]) => {
-        queryParams.set(key, value)
-    })
-
-    let queryString = Array.from(queryParams.entries()).length > 0
-        ? '?'+params.toString()
-        : ''
-
-    return window.location.origin + window.location.pathname + '?'+queryString + window.location.hash
-}

+ 1 - 1
packages/intersect/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/intersect",
-    "version": "3.12.1",
+    "version": "3.13.3",
     "description": "Trigger JavaScript when an element enters the viewport",
     "homepage": "https://alpinejs.dev/plugins/intersect",
     "repository": {

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

@@ -4,7 +4,7 @@ export default function (Alpine) {
 
         let options = {
             rootMargin: getRootMargin(modifiers),
-            threshold: getThreshhold(modifiers),
+            threshold: getThreshold(modifiers),
         }
 
         let observer = new IntersectionObserver(entries => {
@@ -26,7 +26,7 @@ export default function (Alpine) {
     })
 }
 
-function getThreshhold(modifiers) {
+function getThreshold(modifiers) {
     if (modifiers.includes('full')) return 0.99
     if (modifiers.includes('half')) return 0.5
     if (! modifiers.includes('threshold')) return 0

+ 1 - 1
packages/mask/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/mask",
-    "version": "3.12.1",
+    "version": "3.13.3",
     "description": "An Alpine plugin for input masking",
     "homepage": "https://alpinejs.dev/plugins/mask",
     "repository": {

+ 19 - 5
packages/mask/src/index.js

@@ -1,6 +1,6 @@
 
 export default function (Alpine) {
-    Alpine.directive('mask', (el, { value, expression }, { effect, evaluateLater }) => {
+    Alpine.directive('mask', (el, { value, expression }, { effect, evaluateLater, cleanup }) => {
         let templateFn = () => expression
         let lastInputValue = ''
 
@@ -43,10 +43,22 @@ export default function (Alpine) {
             if (el._x_model) el._x_model.set(el.value)
         })
 
-        el.addEventListener('input', () => processInputValue(el))
+        const controller = new AbortController()
+
+        cleanup(() => {
+            controller.abort()
+        })
+
+        el.addEventListener('input', () => processInputValue(el), {
+            signal: controller.signal,
+            // Setting this as a capture phase listener to ensure it runs
+            // before wire:model or x-model added as a latent binding...
+            capture: true,
+        })
+
         // Don't "restoreCursorPosition" on "blur", because Safari
         // will re-focus the input and cause a focus trap.
-        el.addEventListener('blur', () => processInputValue(el, false))
+        el.addEventListener('blur', () => processInputValue(el, false), { signal: controller.signal })
 
         function processInputValue (el, shouldRestoreCursor = true) {
             let input = el.value
@@ -174,7 +186,9 @@ export function formatMoney(input, delimiter = '.', thousands, precision = 2) {
     if (input === '-') return '-'
     if (/^\D+$/.test(input)) return '9'
 
-    thousands = thousands ?? (delimiter === "," ? "." : ",")
+    if (thousands === null || thousands === undefined) {
+        thousands = delimiter === "," ? "." : ","
+    }
 
     let addThousands = (input, thousands) => {
         let output = ''
@@ -201,7 +215,7 @@ export function formatMoney(input, delimiter = '.', thousands, precision = 2) {
 
     template = `${minus}${addThousands(template, thousands)}`
 
-    if (precision > 0 && input.includes(delimiter)) 
+    if (precision > 0 && input.includes(delimiter))
         template += `${delimiter}` + '9'.repeat(precision)
 
     queueMicrotask(() => {

+ 1 - 1
packages/morph/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/morph",
-    "version": "3.12.1",
+    "version": "3.13.3",
     "description": "Diff and patch a block of HTML on a page with an HTML template",
     "homepage": "https://alpinejs.dev/plugins/morph",
     "repository": {

+ 0 - 75
packages/morph/src/dom.js

@@ -1,75 +0,0 @@
-
-export function createElement(html) {
-    const template = document.createElement('template')
-    template.innerHTML = html
-    return template.content.firstElementChild
-}
-
-export function textOrComment(el) {
-    return el.nodeType === 3
-        || el.nodeType === 8
-}
-
-export let dom = {
-    replace(children, old, replacement) {
-        let index = children.indexOf(old)
-
-        if (index === -1) throw 'Cant find element in children'
-
-        old.replaceWith(replacement)
-
-        children[index] = replacement
-
-        return children
-    },
-    before(children, reference, subject) {
-        let index = children.indexOf(reference)
-
-        if (index === -1) throw 'Cant find element in children'
-
-        reference.before(subject)
-
-        children.splice(index, 0, subject)
-
-        return children
-    },
-    append(children, subject, appendFn) {
-        let last = children[children.length - 1]
-
-        appendFn(subject)
-
-        children.push(subject)
-
-        return children
-    },
-    remove(children, subject) {
-        let index = children.indexOf(subject)
-
-        if (index === -1) throw 'Cant find element in children'
-
-        subject.remove()
-
-        return children.filter(i => i !== subject)
-    },
-    first(children) {
-        return this.teleportTo(children[0])
-    },
-    next(children, reference) {
-        let index = children.indexOf(reference)
-
-        if (index === -1) return
-
-        return this.teleportTo(this.teleportBack(children[index + 1]))
-    },
-    teleportTo(el) {
-        if (! el) return el
-        if (el._x_teleport) return el._x_teleport
-        return el
-    },
-    teleportBack(el) {
-        if (! el) return el
-        if (el._x_teleportBack) return el._x_teleportBack
-        return el
-    }
-}
-

+ 173 - 89
packages/morph/src/morph.js

@@ -1,4 +1,3 @@
-import { dom, createElement, textOrComment} from './dom.js'
 
 let resolveStep = () => {}
 
@@ -13,14 +12,7 @@ export function morph(from, toHtml, options) {
 
     let fromEl
     let toEl
-    let key
-        ,lookahead
-        ,updating
-        ,updated
-        ,removing
-        ,removed
-        ,adding
-        ,added
+    let key, lookahead, updating, updated, removing, removed, adding, added
 
     function assignOptions(options = {}) {
         let defaultGetKey = el => el.getAttribute('key')
@@ -37,25 +29,22 @@ export function morph(from, toHtml, options) {
     }
 
     function patch(from, to) {
-        // This is a time saver, however, it won't catch differences in nested <template> tags.
-        // I'm leaving this here as I believe it's an important speed improvement, I just
-        // don't see a way to enable it currently:
-        //
-        // if (from.isEqualNode(to)) return
-
         if (differentElementNamesTypesOrKeys(from, to)) {
-            // Swap elements...
-            return patchElement(from, to)
+            return swapElements(from, to)
         }
 
         let updateChildrenOnly = false
 
         if (shouldSkip(updating, from, to, () => updateChildrenOnly = true)) return
 
-        window.Alpine && initializeAlpineOnTo(from, to, () => updateChildrenOnly = true)
+        // Initialize the server-side HTML element with Alpine...
+        if (from.nodeType === 1 && window.Alpine) {
+            window.Alpine.cloneNode(from, to)
+        }
 
         if (textOrComment(to)) {
             patchNodeValue(from, to)
+
             updated(from, to)
 
             return
@@ -67,9 +56,7 @@ export function morph(from, toHtml, options) {
 
         updated(from, to)
 
-        patchChildren(Array.from(from.childNodes), Array.from(to.childNodes), (toAppend) => {
-            from.appendChild(toAppend)
-        })
+        patchChildren(from, to)
     }
 
     function differentElementNamesTypesOrKeys(from, to) {
@@ -78,14 +65,14 @@ export function morph(from, toHtml, options) {
             || getKey(from) != getKey(to)
     }
 
-    function patchElement(from, to) {
+    function swapElements(from, to) {
         if (shouldSkip(removing, from)) return
 
         let toCloned = to.cloneNode(true)
 
         if (shouldSkip(adding, toCloned)) return
 
-        dom.replace([from], from, toCloned)
+        from.replaceWith(toCloned)
 
         removed(from)
         added(toCloned)
@@ -101,6 +88,8 @@ export function morph(from, toHtml, options) {
     }
 
     function patchAttributes(from, to) {
+        if (from._x_transitioning) return
+
         if (from._x_isShown && ! to._x_isShown) {
             return
         }
@@ -130,108 +119,124 @@ export function morph(from, toHtml, options) {
         }
     }
 
-    function patchChildren(fromChildren, toChildren, appendFn) {
-        // I think I can get rid of this for now:
-        let fromKeyDomNodeMap = {} // keyToMap(fromChildren)
+    function patchChildren(from, to) {
+        // If we hit a <template x-teleport="body">,
+        // let's use the teleported nodes for this patch...
+        if (from._x_teleport) from = from._x_teleport
+        if (to._x_teleport) to = to._x_teleport
+
+        let fromKeys = keyToMap(from.children)
         let fromKeyHoldovers = {}
 
-        let currentTo = dom.first(toChildren)
-        let currentFrom = dom.first(fromChildren)
+        let currentTo = getFirstNode(to)
+        let currentFrom = getFirstNode(from)
 
         while (currentTo) {
+            // If the "from" element has a dynamically bound "id" (x-bind:id="..."),
+            // Let's transfer it to the "to" element so that there isn't a key mismatch...
+            seedingMatchingId(currentTo, currentFrom)
+
             let toKey = getKey(currentTo)
             let fromKey = getKey(currentFrom)
 
-            // Add new elements
+            // Add new elements...
             if (! currentFrom) {
                 if (toKey && fromKeyHoldovers[toKey]) {
                     // Add element (from key)...
                     let holdover = fromKeyHoldovers[toKey]
 
-                    fromChildren = dom.append(fromChildren, holdover, appendFn)
+                    from.appendChild(holdover)
+
                     currentFrom = holdover
                 } else {
                     if(! shouldSkip(adding, currentTo)) {
                         // Add element...
                         let clone = currentTo.cloneNode(true)
 
-                        fromChildren = dom.append(fromChildren, clone, appendFn)
+                        from.appendChild(clone)
 
                         added(clone)
                     }
 
-                    currentTo = dom.next(toChildren, currentTo)
+                    currentTo = getNextSibling(to, currentTo)
 
                     continue
                 }
             }
 
             // Handle conditional markers (presumably added by backends like Livewire)...
-            let isIf = node => node.nodeType === 8 && node.textContent === ' __BLOCK__ '
-            let isEnd = node => node.nodeType === 8 && node.textContent === ' __ENDBLOCK__ '
+            let isIf = node => node && node.nodeType === 8 && node.textContent === '[if BLOCK]><![endif]'
+            let isEnd = node => node && node.nodeType === 8 && node.textContent === '[if ENDBLOCK]><![endif]'
 
             if (isIf(currentTo) && isIf(currentFrom)) {
-                let newFromChildren = []
-                let appendPoint
                 let nestedIfCount = 0
+
+                let fromBlockStart = currentFrom
+
                 while (currentFrom) {
-                    let next = dom.next(fromChildren, currentFrom)
+                    let next = getNextSibling(from, currentFrom)
 
                     if (isIf(next)) {
                         nestedIfCount++
                     } else if (isEnd(next) && nestedIfCount > 0) {
                         nestedIfCount--
                     } else if (isEnd(next) && nestedIfCount === 0) {
-                        currentFrom = dom.next(fromChildren, next)
-                        appendPoint = next
+                        currentFrom = next
 
                         break;
                     }
 
-                    newFromChildren.push(next)
                     currentFrom = next
                 }
 
-                let newToChildren = []
+                let fromBlockEnd = currentFrom
+
                 nestedIfCount = 0
+
+                let toBlockStart = currentTo
+
                 while (currentTo) {
-                    let next = dom.next(toChildren, currentTo)
+                    let next = getNextSibling(to, currentTo)
 
                     if (isIf(next)) {
                         nestedIfCount++
                     } else if (isEnd(next) && nestedIfCount > 0) {
                         nestedIfCount--
                     } else if (isEnd(next) && nestedIfCount === 0) {
-                        currentTo = dom.next(toChildren, next)
+                        currentTo = next
 
                         break;
                     }
 
-                    newToChildren.push(next)
                     currentTo = next
                 }
 
-                patchChildren(newFromChildren, newToChildren, node => appendPoint.before(node))
+                let toBlockEnd = currentTo
+
+                let fromBlock = new Block(fromBlockStart, fromBlockEnd)
+                let toBlock = new Block(toBlockStart, toBlockEnd)
+
+                patchChildren(fromBlock, toBlock)
 
                 continue
             }
 
             // Lookaheads should only apply to non-text-or-comment elements...
-            if (currentFrom.nodeType === 1 && lookahead) {
-                let nextToElementSibling = dom.next(toChildren, currentTo)
+            if (currentFrom.nodeType === 1 && lookahead && ! currentFrom.isEqualNode(currentTo)) {
+                let nextToElementSibling = getNextSibling(to, currentTo)
 
                 let found = false
 
                 while (! found && nextToElementSibling) {
-                    if (currentFrom.isEqualNode(nextToElementSibling)) {
+                    if (nextToElementSibling.nodeType === 1 && currentFrom.isEqualNode(nextToElementSibling)) {
                         found = true; // This ";" needs to be here...
 
-                        [fromChildren, currentFrom] = addNodeBefore(fromChildren, currentTo, currentFrom)
+                        currentFrom = addNodeBefore(from, currentTo, currentFrom)
 
                         fromKey = getKey(currentFrom)
                     }
 
-                    nextToElementSibling = dom.next(toChildren, nextToElementSibling)
+                    nextToElementSibling = getNextSibling(to, nextToElementSibling)
                 }
             }
 
@@ -239,37 +244,37 @@ export function morph(from, toHtml, options) {
                 if (! toKey && fromKey) {
                     // No "to" key...
                     fromKeyHoldovers[fromKey] = currentFrom; // This ";" needs to be here...
-                    [fromChildren, currentFrom] = addNodeBefore(fromChildren, currentTo, currentFrom)
-                    fromChildren = dom.remove(fromChildren, fromKeyHoldovers[fromKey])
-                    currentFrom = dom.next(fromChildren, currentFrom)
-                    currentTo = dom.next(toChildren, currentTo)
+                    currentFrom = addNodeBefore(from, currentTo, currentFrom)
+                    fromKeyHoldovers[fromKey].remove()
+                    currentFrom = getNextSibling(from, currentFrom)
+                    currentTo = getNextSibling(to, currentTo)
 
                     continue
                 }
 
                 if (toKey && ! fromKey) {
-                    if (fromKeyDomNodeMap[toKey]) {
+                    if (fromKeys[toKey]) {
                         // No "from" key...
-                        fromChildren = dom.replace(fromChildren, currentFrom, fromKeyDomNodeMap[toKey])
-                        currentFrom = fromKeyDomNodeMap[toKey]
+                        currentFrom.replaceWith(fromKeys[toKey])
+                        currentFrom = fromKeys[toKey]
                     }
                 }
 
                 if (toKey && fromKey) {
-                    let fromKeyNode = fromKeyDomNodeMap[toKey]
+                    let fromKeyNode = fromKeys[toKey]
 
                     if (fromKeyNode) {
                         // Move "from" key...
                         fromKeyHoldovers[fromKey] = currentFrom
-                        fromChildren = dom.replace(fromChildren, currentFrom, fromKeyNode)
+                        currentFrom.replaceWith(fromKeyNode)
                         currentFrom = fromKeyNode
                     } else {
                         // Swap elements with keys...
                         fromKeyHoldovers[fromKey] = currentFrom; // This ";" needs to be here...
-                        [fromChildren, currentFrom] = addNodeBefore(fromChildren, currentTo, currentFrom)
-                        fromChildren = dom.remove(fromChildren, fromKeyHoldovers[fromKey])
-                        currentFrom = dom.next(fromChildren, currentFrom)
-                        currentTo = dom.next(toChildren, currentTo)
+                        currentFrom = addNodeBefore(from, currentTo, currentFrom)
+                        fromKeyHoldovers[fromKey].remove()
+                        currentFrom = getNextSibling(from, currentFrom)
+                        currentTo = getNextSibling(to, currentTo)
 
                         continue
                     }
@@ -277,24 +282,26 @@ export function morph(from, toHtml, options) {
             }
 
             // Get next from sibling before patching in case the node is replaced
-            let currentFromNext = currentFrom && dom.next(fromChildren, currentFrom)
+            let currentFromNext = currentFrom && getNextSibling(from, currentFrom) //dom.next(from, fromChildren, currentFrom))
 
             // Patch elements
             patch(currentFrom, currentTo)
 
-            currentTo = currentTo && dom.next(toChildren, currentTo)
+            currentTo = currentTo && getNextSibling(to, currentTo) // dom.next(from, toChildren, currentTo))
+
             currentFrom = currentFromNext
         }
 
-        // Cleanup extra froms.
+        // Cleanup extra forms.
         let removals = []
 
         // We need to collect the "removals" first before actually
         // removing them so we don't mess with the order of things.
         while (currentFrom) {
-            if(! shouldSkip(removing, currentFrom)) removals.push(currentFrom)
+            if (! shouldSkip(removing, currentFrom)) removals.push(currentFrom)
 
-            currentFrom = dom.next(fromChildren, currentFrom)
+            // currentFrom = dom.next(fromChildren, currentFrom)
+            currentFrom = getNextSibling(from, currentFrom)
         }
 
         // Now we can do the actual removals.
@@ -314,29 +321,29 @@ export function morph(from, toHtml, options) {
     function keyToMap(els) {
         let map = {}
 
-        els.forEach(el => {
+        for (let el of els) {
             let theKey = getKey(el)
 
             if (theKey) {
                 map[theKey] = el
             }
-        })
+        }
 
         return map
     }
 
-    function addNodeBefore(children, node, beforeMe) {
+    function addNodeBefore(parent, node, beforeMe) {
         if(! shouldSkip(adding, node)) {
             let clone = node.cloneNode(true)
 
-            children = dom.before(children, beforeMe, clone)
+            parent.insertBefore(clone, beforeMe)
 
             added(clone)
 
-            return [children, clone]
+            return clone
         }
 
-        return [children, node]
+        return node
     }
 
     // Finally we morph the element
@@ -346,12 +353,14 @@ export function morph(from, toHtml, options) {
     fromEl = from
     toEl = typeof toHtml === 'string' ? createElement(toHtml) : toHtml
 
-    // If there is no x-data on the element we're morphing,
-    // let's seed it with the outer Alpine scope on the page.
     if (window.Alpine && window.Alpine.closestDataStack && ! from._x_dataStack) {
+        // Just in case a part of this template uses Alpine scope from somewhere
+        // higher in the DOM tree, we'll find that state and replace it on the root
+        // element so everything is synced up accurately.
         toEl._x_dataStack = window.Alpine.closestDataStack(from)
 
-        toEl._x_dataStack && window.Alpine.clone(from, toEl)
+        // We will kick off a clone on the root element.
+        toEl._x_dataStack && window.Alpine.cloneNode(from, toEl)
     }
 
     patch(from, toEl)
@@ -363,10 +372,9 @@ export function morph(from, toHtml, options) {
     return from
 }
 
-morph.step = () => resolveStep()
-morph.log = (theLogger) => {
-    logger = theLogger
-}
+// These are legacy holdovers that don't do anything anymore...
+morph.step = () => {}
+morph.log = () => {}
 
 function shouldSkip(hook, ...args) {
     let skip = false
@@ -376,18 +384,85 @@ function shouldSkip(hook, ...args) {
     return skip
 }
 
-function initializeAlpineOnTo(from, to, childrenOnly) {
-    if (from.nodeType !== 1) return
+let patched = false
 
-    // If the element we are updating is an Alpine component...
-    if (from._x_dataStack) {
-        // Then temporarily clone it (with it's data) to the "to" element.
-        // This should simulate backend Livewire being aware of Alpine changes.
-        window.Alpine.clone(from, to)
+export function createElement(html) {
+    const template = document.createElement('template')
+    template.innerHTML = html
+    return template.content.firstElementChild
+}
+
+export function textOrComment(el) {
+    return el.nodeType === 3
+        || el.nodeType === 8
+}
+
+// "Block"s are used when morphing with conditional markers.
+// They allow us to patch isolated portions of a list of
+// siblings in a DOM tree...
+class Block {
+    constructor(start, end) {
+        // We're assuming here that the start and end caps are comment blocks...
+        this.startComment = start
+        this.endComment = end
+    }
+
+    get children() {
+        let children = [];
+
+        let currentNode = this.startComment.nextSibling
+
+        while (currentNode && currentNode !== this.endComment) {
+            children.push(currentNode)
+
+            currentNode = currentNode.nextSibling
+        }
+
+        return children
+    }
+
+    appendChild(child) {
+        this.endComment.before(child)
+    }
+
+    get firstChild() {
+        let first = this.startComment.nextSibling
+
+        if (first === this.endComment) return
+
+        return first
+    }
+
+    nextNode(reference) {
+        let next = reference.nextSibling
+
+        if (next === this.endComment) return
+
+        return next
+    }
+
+    insertBefore(newNode, reference) {
+        reference.before(newNode)
+
+        return newNode
     }
 }
 
-let patched = false
+function getFirstNode(parent) {
+    return parent.firstChild
+}
+
+function getNextSibling(parent, reference) {
+    let next
+
+    if (parent instanceof Block) {
+        next =  parent.nextNode(reference)
+    } else {
+        next = reference.nextSibling
+    }
+
+    return next
+}
 
 function monkeyPatchDomSetAttributeToAllowAtSymbols() {
     if (patched) return
@@ -415,3 +490,12 @@ function monkeyPatchDomSetAttributeToAllowAtSymbols() {
         this.setAttributeNode(attr)
     }
 }
+
+function seedingMatchingId(to, from) {
+    let fromId = from && from._x_bindings && from._x_bindings.id
+
+    if (! fromId) return
+
+    to.setAttribute('id', fromId)
+    to.id = fromId
+}

+ 1 - 1
packages/morph/src/old_morph.js

@@ -285,7 +285,7 @@ export async function morph(from, toHtml, options) {
             currentFrom = currentFromNext
         }
 
-        // Cleanup extra froms.
+        // Cleanup extra forms.
         let removals = []
 
         // We need to collect the "removals" first before actually

+ 0 - 156
packages/navigate/src/bar.js

@@ -1,156 +0,0 @@
-import NProgress from 'nprogress'
-
-NProgress.configure({ minimum: 0.1 });
-NProgress.configure({ trickleSpeed: 200 });
-
-injectStyles()
-
-let inProgress = false
-export function showAndStartProgressBar() {
-    inProgress = true
-    // Only show progress bar if it's been a little bit...
-    setTimeout(() => {
-        if (! inProgress) return
-        NProgress.start()
-    }, 150)
-
-    // createBar()
-
-    // incrementBar()
-}
-
-export function finishAndHideProgressBar() {
-    inProgress = false
-    NProgress.done()
-    NProgress.remove()
-
-    // finishProgressBar(); destroyBar()
-}
-
-function createBar() {
-    let bar = document.createElement('div')
-
-    bar.setAttribute('id', 'alpine-progress-bar')
-    bar.setAttribute('x-navigate:persist', 'alpine-progress-bar')
-    bar.setAttribute('style', `
-        width: 100%;
-        height: 5px;
-        background: black;
-        position: absolute;
-        top: 0;
-        left: 0;
-        right: 0;
-        transition: all 0.5s ease;
-        transform: scaleX(0);
-        transform-origin: left;
-    `)
-
-    document.body.appendChild(bar)
-
-    return bar
-}
-
-function incrementBar(goal = .1) {
-    let bar = document.getElementById('alpine-progress-bar')
-
-    if (! bar) return
-
-    let percentage = Number(bar.style.transform.match(/scaleX\((.+)\)/)[1])
-
-    if (percentage > 1) return
-
-    bar.style.transform = 'scaleX(' + goal + ')'
-
-    setTimeout(() => {
-        incrementBar(percentage + .1)
-    }, 50)
-}
-
-function finishProgressBar(callback) {
-    let bar = document.getElementById('alpine-progress-bar')
-    bar.style.transform = 'scaleX(1)'
-}
-
-function destroyBar() {
-    document.getElementById('alpine-progress-bar').remove()
-}
-
-function injectStyles() {
-    let style = document.createElement('style')
-    style.innerHTML = `/* Make clicks pass-through */
-    #nprogress {
-      pointer-events: none;
-    }
-
-    #nprogress .bar {
-    //   background: #FC70A9;
-      background: #29d;
-
-      position: fixed;
-      z-index: 1031;
-      top: 0;
-      left: 0;
-
-      width: 100%;
-      height: 2px;
-    }
-
-    /* Fancy blur effect */
-    #nprogress .peg {
-      display: block;
-      position: absolute;
-      right: 0px;
-      width: 100px;
-      height: 100%;
-      box-shadow: 0 0 10px #29d, 0 0 5px #29d;
-      opacity: 1.0;
-
-      -webkit-transform: rotate(3deg) translate(0px, -4px);
-          -ms-transform: rotate(3deg) translate(0px, -4px);
-              transform: rotate(3deg) translate(0px, -4px);
-    }
-
-    /* Remove these to get rid of the spinner */
-    #nprogress .spinner {
-      display: block;
-      position: fixed;
-      z-index: 1031;
-      top: 15px;
-      right: 15px;
-    }
-
-    #nprogress .spinner-icon {
-      width: 18px;
-      height: 18px;
-      box-sizing: border-box;
-
-      border: solid 2px transparent;
-      border-top-color: #29d;
-      border-left-color: #29d;
-      border-radius: 50%;
-
-      -webkit-animation: nprogress-spinner 400ms linear infinite;
-              animation: nprogress-spinner 400ms linear infinite;
-    }
-
-    .nprogress-custom-parent {
-      overflow: hidden;
-      position: relative;
-    }
-
-    .nprogress-custom-parent #nprogress .spinner,
-    .nprogress-custom-parent #nprogress .bar {
-      position: absolute;
-    }
-
-    @-webkit-keyframes nprogress-spinner {
-      0%   { -webkit-transform: rotate(0deg); }
-      100% { -webkit-transform: rotate(360deg); }
-    }
-    @keyframes nprogress-spinner {
-      0%   { transform: rotate(0deg); }
-      100% { transform: rotate(360deg); }
-    }
-    `
-    document.head.appendChild(style)
-}

+ 0 - 9
packages/navigate/src/bus.js

@@ -1,9 +0,0 @@
-let listeners = {}
-
-export function listen(event, callback) {
-    listeners[event] = [...(listeners[event] || []), callback]
-}
-
-export function emit(event, ...props) {
-    (listeners[event] || []).forEach(handle => handle(...props))
-}

+ 0 - 6
packages/navigate/src/fetch.js

@@ -1,6 +0,0 @@
-
-export function fetchHtml(destination, callback) {
-    fetch(destination.pathname).then(i => i.text()).then(html => {
-        callback(html)
-    })
-}

+ 0 - 71
packages/navigate/src/history.js

@@ -1,71 +0,0 @@
-
-export function updateCurrentPageHtmlInHistoryStateForLaterBackButtonClicks() {
-    // Create a history state entry for the initial page load.
-    // (This is so later hitting back can restore this page).
-    let url = new URL(window.location.href, document.baseURI)
-
-    replaceUrl(url, document.documentElement.outerHTML)
-}
-
-export function whenTheBackOrForwardButtonIsClicked(callback) {
-    window.addEventListener('popstate', e => {
-        let { html } = fromSessionStorage(e)
-
-        callback(html)
-    })
-}
-
-export function updateUrlAndStoreLatestHtmlForFutureBackButtons(html, destination) {
-    pushUrl(destination, html)
-}
-
-export function pushUrl(url, html) {
-    updateUrl('pushState', url, html)
-}
-
-export function replaceUrl(url, html) {
-    updateUrl('replaceState', url, html)
-}
-
-function updateUrl(method, url, html) {
-    let key = (new Date).getTime()
-
-    tryToStoreInSession(key, JSON.stringify({ html: html }))
-
-    let state = Object.assign(history.state || {}, { alpine: key })
-
-    // 640k character limit:
-    history[method](state, document.title, url)
-}
-
-export function fromSessionStorage(event) {
-    if (! event.state.alpine) return {}
-
-    let state = JSON.parse(sessionStorage.getItem('alpine:'+event.state.alpine))
-
-    return state
-}
-
-function tryToStoreInSession(timestamp, value) {
-    // sessionStorage has a max storage limit (usally 5MB).
-    // If we meet that limit, we'll start removing entries
-    // (oldest first), until there's enough space to store
-    // the new one.
-    try {
-        sessionStorage.setItem('alpine:'+timestamp, value)
-    } catch (error) {
-        // 22 is Chrome, 1-14 is other browsers.
-        if (! [22, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14].includes(error.code)) return
-
-        let oldestTimestamp = Object.keys(sessionStorage)
-            .map(key => Number(key.replace('alpine:', '')))
-            .sort()
-            .shift()
-
-        if (! oldestTimestamp) return
-
-        sessionStorage.removeItem('alpine:'+oldestTimestamp)
-
-        tryToStoreInSession(timestamp, value)
-    }
-}

+ 1 - 219
packages/navigate/src/index.js

@@ -1,219 +1 @@
-import { transition } from "alpinejs/src/directives/x-transition"
-import { finishAndHideProgressBar, showAndStartProgressBar } from "./bar"
-import { fetchHtml } from "./fetch"
-import { updateCurrentPageHtmlInHistoryStateForLaterBackButtonClicks, updateUrlAndStoreLatestHtmlForFutureBackButtons, whenTheBackOrForwardButtonIsClicked } from "./history"
-import { extractDestinationFromLink, hijackNewLinksOnThePage, whenALinkIsClicked, whenALinkIsHovered } from "./links"
-import { swapCurrentPageWithNewHtml } from "./page"
-import { putPersistantElementsBack, storePersistantElementsForLater } from "./persist"
-import { getPretchedHtmlOr, prefetchHtml, storeThePrefetchedHtmlForWhenALinkIsClicked } from "./prefetch"
-import { restoreScrollPosition, storeScrollInformationInHtmlBeforeNavigatingAway } from "./scroll"
-
-let enablePrefetch = true
-let enablePersist = true
-let showProgressBar = true
-let restoreScroll = true
-let autofocus = false
-
-export default function (Alpine) {
-    updateCurrentPageHtmlInHistoryStateForLaterBackButtonClicks()
-
-    enablePrefetch && whenALinkIsHovered((el) => {
-        let forDestination = extractDestinationFromLink(el)
-
-        prefetchHtml(forDestination, html => {
-            storeThePrefetchedHtmlForWhenALinkIsClicked(html, forDestination)
-        })
-    })
-
-    whenALinkIsClicked((el) => {
-        showProgressBar && showAndStartProgressBar()
-
-        let fromDestination = extractDestinationFromLink(el)
-
-        fetchHtmlOrUsePrefetchedHtml(fromDestination, html => {
-            restoreScroll && storeScrollInformationInHtmlBeforeNavigatingAway()
-
-            showProgressBar && finishAndHideProgressBar()
-
-            updateCurrentPageHtmlInHistoryStateForLaterBackButtonClicks()
-
-            preventAlpineFromPickingUpDomChanges(Alpine, andAfterAllThis => {
-                enablePersist && storePersistantElementsForLater()
-
-                swapCurrentPageWithNewHtml(html, () => {
-                    enablePersist && putPersistantElementsBack()
-
-                    // Added setTimeout here to detect a currently hovered prefetch link...
-                    // (hack for laracon)
-                    setTimeout(() => hijackNewLinksOnThePage())
-
-                    restoreScroll && restoreScrollPosition()
-
-                    fireEventForOtherLibariesToHookInto()
-
-                    updateUrlAndStoreLatestHtmlForFutureBackButtons(html, fromDestination)
-
-                    andAfterAllThis(() => {
-                        autofocus && autofocusElementsWithTheAutofocusAttribute()
-
-                        nowInitializeAlpineOnTheNewPage(Alpine)
-                    })
-                })
-
-            })
-        })
-    })
-
-    whenTheBackOrForwardButtonIsClicked((html) => {
-        // @todo: see if there's a way to update the current HTML BEFORE
-        // the back button is hit, and not AFTER:
-        storeScrollInformationInHtmlBeforeNavigatingAway()
-        // updateCurrentPageHtmlInHistoryStateForLaterBackButtonClicks()
-
-        preventAlpineFromPickingUpDomChanges(Alpine, andAfterAllThis => {
-            enablePersist && storePersistantElementsForLater()
-
-            swapCurrentPageWithNewHtml(html, andThen => {
-                enablePersist && putPersistantElementsBack()
-
-                hijackNewLinksOnThePage()
-
-                restoreScroll && restoreScrollPosition()
-
-                fireEventForOtherLibariesToHookInto()
-
-                andAfterAllThis(() => {
-                    autofocus && autofocusElementsWithTheAutofocusAttribute()
-
-                    nowInitializeAlpineOnTheNewPage(Alpine)
-                })
-            })
-
-        })
-    })
-}
-
-function fetchHtmlOrUsePrefetchedHtml(fromDestination, callback) {
-    getPretchedHtmlOr(fromDestination, callback, () => {
-        fetchHtml(fromDestination, callback)
-    })
-}
-
-function preventAlpineFromPickingUpDomChanges(Alpine, callback) {
-    Alpine.stopObservingMutations()
-
-    callback((afterAllThis) => {
-        Alpine.startObservingMutations()
-
-        setTimeout(() => {
-            afterAllThis()
-        })
-    })
-}
-
-function fireEventForOtherLibariesToHookInto() {
-    document.dispatchEvent(new CustomEvent('alpine:navigated', { bubbles: true }))
-}
-
-function nowInitializeAlpineOnTheNewPage(Alpine) {
-    Alpine.initTree(document.body, undefined, (el, skip) => {
-        if (el._x_wasPersisted) skip()
-    })
-}
-
-function autofocusElementsWithTheAutofocusAttribute() {
-    document.querySelector('[autofocus]') && document.querySelector('[autofocus]').focus()
-}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-    // Alpine.magic('history', (el, { interceptor }) =>  {
-    //     let alias
-
-    //     return interceptor((initialValue, getter, setter, path, key) => {
-    //         let pause = false
-    //         let queryKey = alias || path
-
-    //         let value = initialValue
-    //         let url = new URL(window.location.href)
-
-    //         if (url.searchParams.has(queryKey)) {
-    //             value = url.searchParams.get(queryKey)
-    //         }
-
-    //         setter(value)
-
-    //         let object = { value }
-
-    //         url.searchParams.set(queryKey, value)
-
-    //         replace(url.toString(), path, object)
-
-    //         window.addEventListener('popstate', (e) => {
-    //             if (! e.state) return
-    //             if (! e.state.alpine) return
-
-    //             Object.entries(e.state.alpine).forEach(([newKey, { value }]) => {
-    //                 if (newKey !== key) return
-
-    //                 pause = true
-
-    //                 Alpine.disableEffectScheduling(() => {
-    //                     setter(value)
-    //                 })
-
-    //                 pause = false
-    //             })
-    //         })
-
-    //         Alpine.effect(() => {
-    //             let value = getter()
-
-    //             if (pause) return
-
-    //             let object = { value }
-
-    //             let url = new URL(window.location.href)
-
-    //             url.searchParams.set(queryKey, value)
-
-    //             push(url.toString(), path, object)
-    //         })
-
-    //         return value
-    //     }, func => {
-    //         func.as = key => { alias = key; return func }
-    //     })
-    // })
-// }
-
-
-
-// function replace(url, key, object) {
-//     let state = window.history.state || {}
-
-//     if (! state.alpine) state.alpine = {}
-
-//     state.alpine[key] = object
-
-//     window.history.replaceState(state, '', url)
-// }
-
-// function push(url, key, object) {
-//     let state = { alpine: {...window.history.state.alpine, ...{[key]: object}} }
-
-//     window.history.pushState(state, '', url)
-// }
+// This plugin has been moved into the livewire/livewire repository until it's more stable and ready to tag.

+ 0 - 51
packages/navigate/src/links.js

@@ -1,51 +0,0 @@
-
-let handleLinkClick = () => {}
-let handleLinkHover = () => {}
-
-export function whenALinkIsClicked(callback) {
-    handleLinkClick = callback
-
-    initializeLinksForClicking()
-}
-
-export function whenALinkIsHovered(callback) {
-    handleLinkHover = callback
-
-    initializeLinksForHovering()
-}
-
-export function extractDestinationFromLink(linkEl) {
-    return new URL(linkEl.getAttribute('href'), document.baseURI)
-}
-
-export function hijackNewLinksOnThePage() {
-    initializeLinksForClicking()
-    initializeLinksForHovering()
-}
-
-function initializeLinksForClicking() {
-    getLinks().forEach(el => {
-        el.addEventListener('click', e => {
-            e.preventDefault()
-
-            handleLinkClick(el)
-        })
-    })
-}
-
-function initializeLinksForHovering() {
-    getLinks()
-        .filter(i => i.hasAttribute('wire:navigate.prefetch'))
-        .forEach(el => {
-            el.addEventListener('mouseenter', e => {
-                handleLinkHover(el)
-            })
-        })
-}
-
-function getLinks() {
-    return Array.from(document.links)
-        .filter(i => i.hasAttribute('wire:navigate')
-        || i.hasAttribute('wire:navigate.prefetch'))
-}
-

+ 0 - 106
packages/navigate/src/page.js

@@ -1,106 +0,0 @@
-import Alpine from "alpinejs/src/alpine"
-
-export function swapCurrentPageWithNewHtml(html, andThen) {
-    let newDocument = (new DOMParser()).parseFromString(html, "text/html")
-    let newBody = document.adoptNode(newDocument.body)
-    let newHead = document.adoptNode(newDocument.head)
-
-    mergeNewHead(newHead)
-    prepNewScriptTagsToRun(newBody)
-
-    transitionOut(document.body)
-
-    // @todo: only setTimeout when applying transitions
-    // setTimeout(() => {
-        let oldBody = document.body
-
-        document.body.replaceWith(newBody)
-
-        Alpine.destroyTree(oldBody)
-
-        transitionIn(newBody)
-
-        andThen()
-    // }, 0)
-}
-
-function transitionOut(body) {
-    return;
-    body.style.transition = 'all .5s ease'
-    body.style.opacity = '0'
-}
-
-function transitionIn(body) {
-    return;
-    body.style.opacity = '0'
-    body.style.transition = 'all .5s ease'
-
-    requestAnimationFrame(() => {
-        body.style.opacity = '1'
-    })
-}
-
-function prepNewScriptTagsToRun(newBody) {
-    newBody.querySelectorAll('script').forEach(i => {
-        if (i.hasAttribute('x-navigate:ignore')) return
-
-        i.replaceWith(cloneScriptTag(i))
-    })
-}
-
-function mergeNewHead(newHead) {
-    let headChildrenHtmlLookup = Array.from(document.head.children).map(i => i.outerHTML)
-
-    // Only add scripts and styles that aren't already loaded on the page.
-    let garbageCollector = document.createDocumentFragment()
-
-    for (child of Array.from(newHead.children)) {
-        if (isAsset(child)) {
-            if (! headChildrenHtmlLookup.includes(child.outerHTML)) {
-                if (isScript(child)) {
-                    document.head.appendChild(cloneScriptTag(child))
-                } else {
-                    document.head.appendChild(child)
-                }
-            } else {
-                garbageCollector.appendChild(child)
-            }
-        }
-    }
-
-    // How to free up the garbage collector?
-
-    // Remove existing non-asset elements like meta, base, title, template.
-    for (child of Array.from(document.head.children)) {
-        if (! isAsset(child)) child.remove()
-    }
-
-    // Add new non-asset elements left over in the new head element.
-    for (child of Array.from(newHead.children)) {
-        document.head.appendChild(child)
-    }
-}
-
-function cloneScriptTag(el) {
-    let script = document.createElement('script')
-
-    script.textContent = el.textContent
-    script.async = el.async
-
-    for (attr of el.attributes) {
-        script.setAttribute(attr.name, attr.value)
-    }
-
-    return script
-}
-
-function isAsset (el) {
-    return (el.tagName.toLowerCase() === 'link' && el.getAttribute('rel').toLowerCase() === 'stylesheet')
-        || el.tagName.toLowerCase() === 'style'
-        || el.tagName.toLowerCase() === 'script'
-}
-
-function isScript (el)   {
-    return el.tagName.toLowerCase() === 'script'
-}
-

+ 0 - 27
packages/navigate/src/persist.js

@@ -1,27 +0,0 @@
-import Alpine from "alpinejs/src/alpine"
-
-let els = {}
-
-export function storePersistantElementsForLater() {
-    els = {}
-
-    document.querySelectorAll('[x-navigate\\:persist]').forEach(i => {
-        els[i.getAttribute('x-navigate:persist')] = i
-
-        Alpine.mutateDom(() => {
-            i.remove()
-        })
-    })
-}
-
-export function putPersistantElementsBack() {
-    document.querySelectorAll('[x-navigate\\:persist]').forEach(i => {
-        let old = els[i.getAttribute('x-navigate:persist')]
-
-        old._x_wasPersisted = true
-
-        Alpine.mutateDom(() => {
-            i.replaceWith(old)
-        })
-    })
-}

+ 0 - 45
packages/navigate/src/prefetch.js

@@ -1,45 +0,0 @@
-
-// Warning: this could cause some memory leaks
-let prefetches = {}
-
-export function prefetchHtml(destination, callback) {
-    let path = destination.pathname
-
-    if (prefetches[path]) return
-
-    prefetches[path] = { finished: false, html: null, whenFinished: () => {} }
-
-    fetch(path).then(i => i.text()).then(html => {
-        callback(html)
-    })
-}
-
-export function storeThePrefetchedHtmlForWhenALinkIsClicked(html, destination) {
-    let state = prefetches[destination.pathname]
-    state.html = html
-    state.finished = true
-    state.whenFinished()
-}
-
-export function getPretchedHtmlOr(destination, receive, ifNoPrefetchExists) {
-    let path = destination.pathname
-
-    if (! prefetches[path]) return ifNoPrefetchExists()
-
-    if (prefetches[path].finished) {
-        let html = prefetches[path].html
-
-        delete prefetches[path]
-
-        return receive(html)
-    } else {
-        prefetches[path].whenFinished = () => {
-            let html = prefetches[path].html
-
-            delete prefetches[path]
-
-            receive(html)
-        }
-    }
-}
-

+ 0 - 24
packages/navigate/src/scroll.js

@@ -1,24 +0,0 @@
-
-export function storeScrollInformationInHtmlBeforeNavigatingAway() {
-    document.body.setAttribute('data-scroll-x', document.body.scrollLeft)
-    document.body.setAttribute('data-scroll-y', document.body.scrollTop)
-
-    document.querySelectorAll(['[x-navigate\\:scroll]', '[wire\\:scroll]']).forEach(el => {
-        el.setAttribute('data-scroll-x', el.scrollLeft)
-        el.setAttribute('data-scroll-y', el.scrollTop)
-    })
-}
-
-export function restoreScrollPosition() {
-    let scroll = el => {
-        el.scrollTo(Number(el.getAttribute('data-scroll-x')), Number(el.getAttribute('data-scroll-y')))
-        el.removeAttribute('data-scroll-x')
-        el.removeAttribute('data-scroll-y')
-    }
-
-    queueMicrotask(() => {
-        scroll(document.body)
-
-        document.querySelectorAll(['[x-navigate\\:scroll]', '[wire\\:scroll]']).forEach(scroll)
-    })
-}

+ 1 - 1
packages/persist/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/persist",
-    "version": "3.12.1",
+    "version": "3.13.3",
     "description": "Persist Alpine data across page loads",
     "homepage": "https://alpinejs.dev/plugins/persist",
     "repository": {

+ 15 - 1
packages/persist/src/index.js

@@ -1,7 +1,21 @@
 export default function (Alpine) {
     let persist = () => {
         let alias
-        let storage = localStorage
+        let storage
+
+        try {
+            storage = localStorage
+        } catch (e) {
+            console.error(e)
+            console.warn('Alpine: $persist is using temporary storage since localStorage is unavailable.')
+
+            let dummy = new Map();
+
+            storage = {
+                getItem: dummy.get.bind(dummy),
+                setItem: dummy.set.bind(dummy)
+            }
+        }
 
         return Alpine.interceptor((initialValue, getter, setter, path, key) => {
             let lookup = alias || `_x_${path}`

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

@@ -11,7 +11,6 @@
 
     <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>

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

@@ -11,7 +11,6 @@
 
     <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>

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

@@ -11,7 +11,6 @@
 
     <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>

+ 2 - 2
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/ui",
-    "version": "3.12.0-beta.0",
+    "version": "3.13.3-beta.4",
     "description": "Headless UI components for Alpine",
     "homepage": "https://alpinejs.dev/components#headless",
     "repository": {
@@ -13,5 +13,5 @@
     "main": "dist/module.cjs.js",
     "module": "dist/module.esm.js",
     "unpkg": "dist/cdn.min.js",
-    "dependencies": {}
+    "devDependencies": {}
 }

+ 381 - 429
packages/ui/src/combobox.js

@@ -1,3 +1,4 @@
+import { generateContext, renderHiddenInputs } from './list-context'
 
 export default function (Alpine) {
     Alpine.directive('combobox', (el, directive, { evaluate }) => {
@@ -9,57 +10,258 @@ export default function (Alpine) {
         else                                         handleRoot(el, Alpine)
     }).before('bind')
 
-    Alpine.magic('comboboxOption', el => {
-        let $data = Alpine.$data(el)
+    Alpine.magic('combobox', el => {
+        let data = Alpine.$data(el)
+
+        return {
+            get value() {
+                return data.__value
+            },
+            get isOpen() {
+                return data.__isOpen
+            },
+            get isDisabled() {
+                return data.__isDisabled
+            },
+            get activeOption() {
+                let active = data.__context?.getActiveItem()
+
+                return active && active.value
+            },
+            get activeIndex() {
+                let active = data.__context?.getActiveItem()
+
+                if (active) {
+                    return Object.values(Alpine.raw(data.__context.items)).findIndex(i => Alpine.raw(active) == Alpine.raw(i))
+                }
 
-        return $data.$item
+                return null
+            },
+        }
     })
 
-    registerListStuff(Alpine)
+    Alpine.magic('comboboxOption', el => {
+        let data = Alpine.$data(el)
+
+        let optionEl = Alpine.findClosest(el, i => i.__optionKey)
+
+        if (! optionEl) throw 'No x-combobox:option directive found...'
+
+        return {
+            get isActive() {
+                return data.__context.isActiveKey(optionEl.__optionKey)
+            },
+            get isSelected() {
+                return data.__isSelected(optionEl)
+            },
+            get isDisabled() {
+                return data.__context.isDisabled(optionEl.__optionKey)
+            },
+        }
+    })
 }
 
 function handleRoot(el, Alpine) {
     Alpine.bind(el, {
-        'x-id'() { return ['headlessui-combobox-button', 'headlessui-combobox-options', 'headlessui-combobox-label'] },
-        'x-list': '__value',
+        // Setup...
+        'x-id'() { return ['alpine-combobox-button', 'alpine-combobox-options', 'alpine-combobox-label'] },
         'x-modelable': '__value',
+
+        // Initialize...
         'x-data'() {
             return {
-                init() {
-                    this.$nextTick(() => {
-                        this.syncInputValue()
+                /**
+                 * Combobox state...
+                 */
+                __ready: false,
+                __value: null,
+                __isOpen: false,
+                __context: undefined,
+                __isMultiple: undefined,
+                __isStatic: false,
+                __isDisabled: undefined,
+                __displayValue: undefined,
+                __compareBy: null,
+                __inputName: null,
+                __isTyping: false,
+                __hold: false,
 
-                        Alpine.effect(() => this.syncInputValue())
+                /**
+                 * Combobox initialization...
+                 */
+                init() {
+                    this.__isMultiple = Alpine.extractProp(el, 'multiple', false)
+                    this.__isDisabled = Alpine.extractProp(el, 'disabled', false)
+                    this.__inputName = Alpine.extractProp(el, 'name', null)
+                    this.__nullable = Alpine.extractProp(el, 'nullable', false)
+                    this.__compareBy = Alpine.extractProp(el, 'by')
+
+                    this.__context = generateContext(Alpine, this.__isMultiple, 'vertical', () => this.__activateSelectedOrFirst())
+
+                    let defaultValue = Alpine.extractProp(el, 'default-value', this.__isMultiple ? [] : null)
+
+                    this.__value = defaultValue
+
+                    // 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(() => {
+                        Alpine.effect(() => {
+                            // Everytime the value changes, we need to re-render the hidden inputs,
+                            // if a user passed the "name" prop...
+                            this.__inputName && renderHiddenInputs(Alpine, this.$el, this.__inputName, this.__value)
+                        })
                     })
                 },
-                __value: null,
-                __disabled: false,
-                __static: false,
-                __hold: false,
-                __displayValue: i => i,
-                __isOpen: false,
-                __optionsEl: null,
+                __startTyping() {
+                    this.__isTyping = true
+                },
+                __stopTyping() {
+                    this.__isTyping = false
+                },
+                __resetInput() {
+                    let input = this.$refs.__input
+
+                    if (! input) return
+
+                    let value = this.__getCurrentValue()
+
+                    input.value = value
+                },
+                __getCurrentValue() {
+                    if (! this.$refs.__input) return ''
+                    if (! this.__value) return ''
+                    if (this.__displayValue) return this.__displayValue(this.__value)
+                    if (typeof this.__value === 'string') return this.__value
+                    return ''
+                },
                 __open() {
-                    // @todo handle disabling the entire combobox.
                     if (this.__isOpen) return
                     this.__isOpen = true
 
-                    this.$list.activateSelectedOrFirst()
+                    let input = this.$refs.__input
+
+                    // Make sure we always notify the parent component
+                    // that the starting value is the empty string
+                    // when we open the combobox (ignoring any existing value)
+                    // to avoid inconsistent displaying.
+                    // Setting the input to empty and back to the real value
+                    // also helps VoiceOver to annunce the content properly
+                    // See https://github.com/tailwindlabs/headlessui/pull/2153
+                    if (input) {
+                        let value = input.value
+                        let { selectionStart, selectionEnd, selectionDirection } = input
+                        input.value = ''
+                        input.dispatchEvent(new Event('change'))
+                        input.value = value
+                        if (selectionDirection !== null) {
+                            input.setSelectionRange(selectionStart, selectionEnd, selectionDirection)
+                        } else {
+                            input.setSelectionRange(selectionStart, selectionEnd)
+                        }
+                    }
+
+                    // 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.__input.focus({ preventScroll: true })
+                        this.__activateSelectedOrFirst()
+                    })
                 },
                 __close() {
-                    this.syncInputValue()
+                    this.__isOpen = false
 
-                    if (this.__static) return
+                    this.__context.deactivate()
+                },
+                __activateSelectedOrFirst(activateSelected = true) {
                     if (! this.__isOpen) return
 
-                    this.__isOpen = false
-                    this.$list.active = null
+                    if (this.__context.hasActive() && this.__context.wasActivatedByKeyPress()) return
+
+                    let firstSelectedValue
+
+                    if (this.__isMultiple) {
+                        let selectedItem = this.__context.getItemsByValues(this.__value)
+
+                        firstSelectedValue = selectedItem.length ? selectedItem[0].value : null
+                    } else {
+                        firstSelectedValue = this.__value
+                    }
+
+                    let firstSelected = null
+                    if (activateSelected && firstSelectedValue) {
+                        firstSelected = this.__context.getItemByValue(firstSelectedValue)
+                    }
+
+                    if (firstSelected) {
+                        this.__context.activateAndScrollToKey(firstSelected.key)
+                        return
+                    }
+
+                    this.__context.activateAndScrollToKey(this.__context.firstKey())
+                },
+                __selectActive() {
+                    let active = this.__context.getActiveItem()
+                    if (active) this.__toggleSelected(active.value)
+                },
+                __selectOption(el) {
+                    let item = this.__context.getItemByEl(el)
+
+                    if (item) this.__toggleSelected(item.value)
+                },
+                __isSelected(el) {
+                    let item = this.__context.getItemByEl(el)
+
+                    if (! item) return false
+                    if (! item.value) return false
+
+                    return this.__hasSelected(item.value)
+                },
+                __toggleSelected(value) {
+                    if (! this.__isMultiple) {
+                        this.__value = value
+
+                        return
+                    }
+
+                    let index = this.__value.findIndex(j => this.__compare(j, value))
+
+                    if (index === -1) {
+                        this.__value.push(value)
+                    } else {
+                        this.__value.splice(index, 1)
+                    }
                 },
-                syncInputValue() {
-                    if (this.$list.selected) this.$refs.__input.value = this.__displayValue(this.$list.selected)
+                __hasSelected(value) {
+                    if (! this.__isMultiple) return this.__compare(this.__value, value)
+
+                    return this.__value.some(i => this.__compare(i, value))
+                },
+                __compare(a, b) {
+                    let by = this.__compareBy
+
+                    if (! by) by = (a, b) => Alpine.raw(a) === Alpine.raw(b)
+
+                    if (typeof by === 'string') {
+                        let property = by
+                        by = (a, b) => {
+                            // Handle null values
+                            if ((! a || typeof a !== 'object') || (! b || typeof b !== 'object')) {
+                                return Alpine.raw(a) === Alpine.raw(b)
+                            }
+
+
+                            return a[property] === b[property];
+                        }
+                    }
+
+                    return by(a, b)
                 },
             }
         },
+        // Register event listeners..
         '@mousedown.window'(e) {
             if (
                 !! ! this.$refs.__input.contains(e.target)
@@ -67,6 +269,7 @@ function handleRoot(el, Alpine) {
                 && ! this.$refs.__options.contains(e.target)
             ) {
                 this.__close()
+                this.__resetInput()
             }
         }
     })
@@ -74,54 +277,116 @@ function handleRoot(el, Alpine) {
 
 function handleInput(el, Alpine) {
     Alpine.bind(el, {
+        // Setup...
         'x-ref': '__input',
-        ':id'() { return this.$id('headlessui-combobox-input') },
+        ':id'() { return this.$id('alpine-combobox-input') },
+
+        // Accessibility attributes...
         'role': 'combobox',
         'tabindex': '0',
-        ':aria-controls'() { return this.$data.__optionsEl && this.$data.__optionsEl.id },
-        ':aria-expanded'() { return this.$data.__disabled ? undefined : this.$data.__isOpen },
-        ':aria-activedescendant'() { return this.$data.$list.activeEl ? this.$data.$list.activeEl.id : null },
+        'aria-autocomplete': 'list',
+
+        // We need to defer this evaluation a bit because $refs that get declared later
+        // in the DOM aren't available yet when x-ref is the result of an Alpine.bind object.
+        async ':aria-controls'() { return await microtask(() => this.$refs.__options && this.$refs.__options.id) },
+        ':aria-expanded'() { return this.$data.__isDisabled ? undefined : this.$data.__isOpen },
+        ':aria-multiselectable'() { return this.$data.__isMultiple ? true : undefined },
+        ':aria-activedescendant'() {
+            if (! this.$data.__context.hasActive()) return
+
+            let active = this.$data.__context.getActiveItem()
+
+            return active ? active.el.id : null
+        },
         ':aria-labelledby'() { return this.$refs.__label ? this.$refs.__label.id : (this.$refs.__button ? this.$refs.__button.id : null) },
+
+        // Initialize...
         'x-init'() {
-            queueMicrotask(() => {
-                Alpine.effect(() => {
-                    this.$data.__disabled = Alpine.bound(this.$el, 'disabled', false)
-                })
+            let displayValueFn = Alpine.extractProp(this.$el, 'display-value')
+            if (displayValueFn) this.$data.__displayValue = displayValueFn
+        },
 
-                let displayValueFn = Alpine.bound(this.$el, 'display-value')
-                if (displayValueFn) this.$data.__displayValue = displayValueFn
-            })
+        // Register listeners...
+        '@input.stop'(e) {
+            if(this.$data.__isTyping) {
+                this.$data.__open();
+                this.$dispatch('change')
+            }
+        },
+        '@blur'() { this.$data.__stopTyping(false) },
+        '@keydown'(e) {
+            queueMicrotask(() => this.$data.__context.activateByKeyEvent(e, false, () => this.$data.__isOpen, () => this.$data.__open(), (state) => this.$data.__isTyping = state))
+        },
+        '@keydown.enter.prevent.stop'() {
+            this.$data.__selectActive()
+
+            this.$data.__stopTyping()
+
+            if (! this.$data.__isMultiple) {
+                this.$data.__close()
+                this.$data.__resetInput()
+            }
         },
-        '@input.stop'() { this.$data.__open(); this.$dispatch('change') },
-        '@change.stop'() {},
-        '@keydown.enter.prevent.stop'() { this.$list.selectActive(); this.$data.__close() },
-        '@keydown'(e) { this.$list.handleKeyboardNavigation(e) },
-        '@keydown.down'(e) { if(! this.$data.__isOpen) this.$data.__open(); },
-        '@keydown.up'(e) { if(! this.$data.__isOpen) this.$data.__open(); },
         '@keydown.escape.prevent'(e) {
             if (! this.$data.__static) e.stopPropagation()
 
+            this.$data.__stopTyping()
             this.$data.__close()
+            this.$data.__resetInput()
+
+        },
+        '@keydown.tab'() {
+            this.$data.__stopTyping()
+            if (this.$data.__isOpen) { this.$data.__close() }
+            this.$data.__resetInput()
+        },
+        '@keydown.backspace'(e) {
+            if (this.$data.__isMultiple) return
+            if (! this.$data.__nullable) return
+
+            let input = e.target
+
+            requestAnimationFrame(() => {
+                if (input.value === '') {
+                    this.$data.__value = null
+
+                    let options = this.$refs.__options
+                    if (options) {
+                        options.scrollTop = 0
+                    }
+
+                    this.$data.__context.deactivate()
+                }
+            })
         },
-        '@keydown.tab'() { if (this.$data.__isOpen) { this.$list.selectActive(); this.$data.__close() }},
     })
 }
 
 function handleButton(el, Alpine) {
     Alpine.bind(el, {
+        // Setup...
         'x-ref': '__button',
-        ':id'() { return this.$id('headlessui-combobox-button') },
+        ':id'() { return this.$id('alpine-combobox-button') },
+
+        // Accessibility attributes...
         'aria-haspopup': 'true',
+        // We need to defer this evaluation a bit because $refs that get declared later
+        // in the DOM aren't available yet when x-ref is the result of an Alpine.bind object.
+        async ':aria-controls'() { return await microtask(() => this.$refs.__options && this.$refs.__options.id) },
         ':aria-labelledby'() { return this.$refs.__label ? [this.$refs.__label.id, this.$el.id].join(' ') : null },
-        ':aria-expanded'() { return this.$data.__disabled ? null : this.$data.__isOpen },
-        ':aria-controls'() { return this.$data.__optionsEl ? this.$data.__optionsEl.id : null },
-        ':disabled'() { return this.$data.__disabled },
+        ':aria-expanded'() { return this.$data.__isDisabled ? null : this.$data.__isOpen },
+        ':disabled'() { return this.$data.__isDisabled },
         'tabindex': '-1',
+
+        // Initialize....
         'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && ! this.$el.hasAttribute('type')) this.$el.type = 'button' },
+
+        // Register listeners...
         '@click'(e) {
-            if (this.$data.__disabled) return
+            if (this.$data.__isDisabled) return
             if (this.$data.__isOpen) {
                 this.$data.__close()
+                this.$data.__resetInput()
             } else {
                 e.preventDefault()
                 this.$data.__open()
@@ -129,422 +394,109 @@ function handleButton(el, Alpine) {
 
             this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
         },
-        '@keydown.down.prevent.stop'() {
-            if (! this.$data.__isOpen) {
-                this.$data.__open()
-                this.$list.activateSelectedOrFirst()
-            }
-
-            this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
-        },
-        '@keydown.up.prevent.stop'() {
-            if (! this.$data.__isOpen) {
-                this.$data.__open()
-                this.$list.activateSelectedOrLast()
-            }
-
-            this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
-        },
-        '@keydown.escape.prevent'(e) {
-            if (! this.$data.__static) e.stopPropagation()
-
-            this.$data.__close()
-            this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
-        },
     })
 }
 
 function handleLabel(el, Alpine) {
     Alpine.bind(el, {
         'x-ref': '__label',
-        ':id'() { return this.$id('headlessui-combobox-label') },
+        ':id'() { return this.$id('alpine-combobox-label') },
         '@click'() { this.$refs.__input.focus({ preventScroll: true }) },
     })
 }
 
 function handleOptions(el, Alpine) {
     Alpine.bind(el, {
+        // Setup...
         'x-ref': '__options',
-        'x-init'() {
-            this.$data.__optionsEl = this.$el
-
-            queueMicrotask(() => {
-                if (Alpine.bound(this.$el, 'static')) {
-                    this.$data.__open()
-                    this.$data.__static = true;
-                }
-
-                if (Alpine.bound(this.$el, 'hold')) {
-                    this.$data.__hold = true;
-                }
-            })
+        ':id'() { return this.$id('alpine-combobox-options') },
 
-            // Add `role="none"` to all non option elements.
-            this.$nextTick(() => {
-                let walker = document.createTreeWalker(
-                    this.$el,
-                    NodeFilter.SHOW_ELEMENT,
-                    { acceptNode: node => {
-                        if (node.getAttribute('role') === 'option') return NodeFilter.FILTER_REJECT
-                        if (node.hasAttribute('role')) return NodeFilter.FILTER_SKIP
-                        return NodeFilter.FILTER_ACCEPT
-                    }},
-                    false
-                )
-
-                while (walker.nextNode()) walker.currentNode.setAttribute('role', 'none')
-            })
-        },
+        // Accessibility attributes...
         'role': 'listbox',
-        ':id'() { return this.$id('headlessui-combobox-options') },
-        ':aria-labelledby'() { return this.$id('headlessui-combobox-button') },
-        ':aria-activedescendant'() { return this.$list.activeEl ? this.$list.activeEl.id : null },
-        'x-show'() { return this.$data.__isOpen },
-    })
-}
+        ':aria-labelledby'() { return this.$refs.__label ? this.$refs.__label.id : (this.$refs.__button ? this.$refs.__button.id : null) },
 
-function handleOption(el, Alpine, directive, evaluate) {
-    let value = evaluate(directive.expression)
+        // Initialize...
+        'x-init'() {
+            this.$data.__isStatic = Alpine.bound(this.$el, 'static', false)
 
-    Alpine.bind(el, {
-        'role': 'option',
-        'x-item'() { return value },
-        ':id'() { return this.$id('headlessui-combobox-option') },
-        ':tabindex'() { return this.$item.disabled ? undefined : '-1' },
-        ':aria-selected'() { return this.$item.selected },
-        ':aria-disabled'() { return this.$item.disabled },
-        '@click'(e) {
-            if (this.$item.disabled) e.preventDefault()
-            this.$item.select()
-            this.$data.__close()
-            this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
-        },
-        '@focus'() {
-            if (this.$item.disabled) return this.$list.deactivate()
-            this.$item.activate()
-        },
-        '@pointermove'() {
-            if (this.$item.disabled || this.$item.active) return
-            this.$item.activate()
-        },
-        '@mousemove'() {
-            if (this.$item.disabled || this.$item.active) return
-            this.$item.activate()
-        },
-        '@pointerleave'() {
-            if (this.$item.disabled || ! this.$item.active || this.$data.__hold) return
-            this.$list.deactivate()
-        },
-        '@mouseleave'() {
-            if (this.$item.disabled || ! this.$item.active || this.$data.__hold) return
-            this.$list.deactivate()
+            if (Alpine.bound(this.$el, 'hold')) {
+                this.$data.__hold = true;
+            }
         },
+
+        'x-show'() { return this.$data.__isStatic ? true : this.$data.__isOpen },
     })
 }
 
-function registerListStuff(Alpine) {
-    Alpine.directive('list', (el, { expression, modifiers }, { evaluateLater, effect }) => {
-        let wrap = modifiers.includes('wrap')
-        let getOuterValue = () => null
-        let setOuterValue = () => {}
-
-        if (expression) {
-            let func = evaluateLater(expression)
-            getOuterValue = () => { let result; func(i => result = i); return result; }
-            let evaluateOuterSet = evaluateLater(`${expression} = __placeholder`)
-            setOuterValue = val => evaluateOuterSet(() => {}, { scope: { '__placeholder': val }})
-        }
-
-        let listEl = el
-
-        el._x_listState = {
-            wrap,
-            reactive: Alpine.reactive({
-                active: null,
-                selected: null,
-            }),
-            get active() { return this.reactive.active },
-            get selected() { return this.reactive.selected },
-            get activeEl() {
-                this.reactive.active
-
-                let item = this.items.find(i => i.value === this.reactive.active)
-
-                return item && item.el
-            },
-            get selectedEl() {
-                let item = this.items.find(i => i.value === this.reactive.selected)
-
-                return item && item.el
-            },
-            set active(value) { this.setActive(value) },
-            set selected(value) { this.setSelected(value) },
-            setSelected(value) {
-                let item = this.items.find(i => i.value === value)
+function handleOption(el, Alpine) {
+    Alpine.bind(el, {
+        // Setup...
+        'x-id'() { return ['alpine-combobox-option'] },
+        ':id'() { return this.$id('alpine-combobox-option') },
 
-                if (item && item.disabled) return
+        // Accessibility attributes...
+        'role': 'option',
+        ':tabindex'() { return this.$comboboxOption.isDisabled ? undefined : '-1' },
 
-                this.reactive.selected = value; setOuterValue(value)
-            },
-            setActive(value) {
-                let item = this.items.find(i => i.value === value)
+        // Only the active element should have aria-selected="true"...
+        'x-effect'() {
+            this.$comboboxOption.isSelected
+                ? el.setAttribute('aria-selected', true)
+                : el.setAttribute('aria-selected', false)
+        },
 
-                if (item && item.disabled) return
+        ':aria-disabled'() { return this.$comboboxOption.isDisabled },
 
-                this.reactive.active = value
-            },
-            deactivate() {
-                this.reactive.active = null
-            },
-            selectActive() {
-                this.selected = this.active
-            },
-            activateSelectedOrFirst() {
-                if (this.selected) this.active = this.selected
-                else this.first()?.activate()
-            },
-            activateSelectedOrLast() {
-                if (this.selected) this.active = this.selected
-                else this.last()?.activate()
-            },
-            items: [],
-            get filteredEls() { return this.items.filter(i => ! i.disabled).map(i => i.el) },
-            addItem(el, value, disabled = false) {
-                this.items.push({ el, value, disabled })
-                this.reorderList()
-            },
-            disableItem(el) {
-                this.items.find(i => i.el === el).disabled = true
-            },
-            removeItem(el) {
-                this.items = this.items.filter(i => i.el !== el)
-                this.reorderList()
-            },
-            reorderList() {
-                this.items = this.items.slice().sort((a, z) => {
-                    if (a === null || z === null) return 0
+        // Initialize...
+        'x-data'() {
+            return {
+                init() {
+                    let key = this.$el.__optionKey = (Math.random() + 1).toString(36).substring(7)
 
-                    let position = a.el.compareDocumentPosition(z.el)
+                    let value = Alpine.extractProp(this.$el, 'value')
+                    let disabled = Alpine.extractProp(this.$el, 'disabled', false, false)
 
-                    if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
-                    if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
-                    return 0
-                })
-            },
-            handleKeyboardNavigation(e) {
-                let item
-
-                switch (e.key) {
-                    case 'Tab':
-                    case 'Backspace':
-                    case 'Delete':
-                    case 'Meta':
-                        break;
-
-                        break;
-                    case ['ArrowDown', 'ArrowRight'][0]: // @todo handle orientation switching.
-                        e.preventDefault(); e.stopPropagation()
-                        item = this.active ? this.next() : this.first()
-                        break;
-
-                    case ['ArrowUp', 'ArrowLeft'][0]:
-                        e.preventDefault(); e.stopPropagation()
-                        item = this.active ? this.prev() : this.last()
-                        break;
-                    case 'Home':
-                    case 'PageUp':
-                        e.preventDefault(); e.stopPropagation()
-                        item = this.first()
-                        break;
-
-                    case 'End':
-                    case 'PageDown':
-                        e.preventDefault(); e.stopPropagation()
-                        item = this.last()
-                        break;
-
-                    default:
-                        if (e.key.length === 1) {
-                            // item = this.search(e.key)
-                        }
-                        break;
+                    // memoize the context as it's not going to change
+                    // and calling this.$data on mouse action is expensive
+                    this.__context.registerItem(key, this.$el, value, disabled)
+                },
+                destroy() {
+                    this.__context.unregisterItem(this.$el.__optionKey)
                 }
+            }
+        },
 
-                item && item.activate(({ el }) => {
-                    setTimeout(() => el.scrollIntoView({ block: 'nearest' }))
-                })
-            },
-            // Todo: the debounce doesn't work.
-            searchQuery: '',
-            clearSearch: Alpine.debounce(function () { this.searchQuery = '' }, 350),
-            search(key) {
-                this.searchQuery += key
-
-                let el = this.filteredEls.find(el => {
-                    return el.textContent.trim().toLowerCase().startsWith(this.searchQuery)
-                })
-
-                let obj = el ? generateItemObject(listEl, el) : null
-
-                this.clearSearch()
-
-                return obj
-            },
-            first() {
-                let el = this.filteredEls[0]
-
-                return el && generateItemObject(listEl, el)
-            },
-            last() {
-                let el = this.filteredEls[this.filteredEls.length-1]
-
-                return el && generateItemObject(listEl, el)
-            },
-            next() {
-                let current = this.activeEl || this.filteredEls[0]
-                let index = this.filteredEls.indexOf(current)
-
-                let el = this.wrap
-                    ? this.filteredEls[index + 1] || this.filteredEls[0]
-                    : this.filteredEls[index + 1] || this.filteredEls[index]
-
-                return el && generateItemObject(listEl, el)
-            },
-            prev() {
-                let current = this.activeEl || this.filteredEls[0]
-                let index = this.filteredEls.indexOf(current)
-
-                let el = this.wrap
-                    ? (index - 1 < 0 ? this.filteredEls[this.filteredEls.length-1] : this.filteredEls[index - 1])
-                    : (index - 1 < 0 ? this.filteredEls[0] : this.filteredEls[index - 1])
-
-                return el && generateItemObject(listEl, el)
-            },
-        }
-
-        effect(() => {
-            el._x_listState.setSelected(getOuterValue())
-        })
-    })
-
-    Alpine.magic('list', (el) => {
-        let listEl = Alpine.findClosest(el, el => el._x_listState)
-
-        return listEl._x_listState
-    })
-
-    Alpine.directive('item', (el, { expression }, { effect, evaluate, cleanup }) => {
-        let value
-        el._x_listItem = true
-
-        if (expression) value = evaluate(expression)
-
-        let listEl = Alpine.findClosest(el, el => el._x_listState)
-
-        console.log(value)
-        listEl._x_listState.addItem(el, value)
+        // Register listeners...
+        '@click'() {
+            if (this.$comboboxOption.isDisabled) return;
 
-        queueMicrotask(() => {
-            Alpine.bound(el, 'disabled') && listEl._x_listState.disableItem(el)
-        })
+            this.__selectOption(this.$el)
 
-        cleanup(() => {
-            listEl._x_listState.removeItem(el)
-            delete el._x_listItem
-        })
-    })
+            if (! this.__isMultiple) {
+                this.__close()
+                this.__resetInput()
+            }
 
-    Alpine.magic('item', el => {
-        let listEl = Alpine.findClosest(el, el => el._x_listState)
-        let itemEl = Alpine.findClosest(el, el => el._x_listItem)
+            this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true }))
+        },
+        '@mouseenter'(e) {
+            this.__context.activateEl(this.$el)
+        },
+        '@mousemove'(e) {
+            if (this.__context.isActiveEl(this.$el)) return
 
-        if (! listEl) throw 'Cant find x-list element'
-        if (! itemEl) throw 'Cant find x-item element'
+            this.__context.activateEl(this.$el)
+        },
+        '@mouseleave'(e) {
+            if (this.__hold) return
 
-        return generateItemObject(listEl, itemEl)
+            this.__context.deactivate()
+        },
     })
+}
 
-    function generateItemObject(listEl, el) {
-        let state = listEl._x_listState
-        let item = listEl._x_listState.items.find(i => i.el === el)
-
-        return {
-            activate(callback = () => {}) {
-                state.setActive(item.value)
-
-                callback(item)
-            },
-            deactivate() {
-                if (Alpine.raw(state.active) === Alpine.raw(item.value)) state.setActive(null)
-            },
-            select(callback = () => {}) {
-                state.setSelected(item.value)
-
-                callback(item)
-            },
-            isFirst() {
-                return state.items.findIndex(i => i.el.isSameNode(el)) === 0
-            },
-            get active() {
-                if (state.reactive.active) return state.reactive.active === item.value
 
-                return null
-            },
-            get selected() {
-                if (state.reactive.selected) return state.reactive.selected === item.value
-
-                return null
-            },
-            get disabled() {
-                return item.disabled
-            },
-            get el() { return item.el },
-            get value() { return item.value },
-        }
-    }
+// Little utility to defer a callback into the microtask queue...
+function microtask(callback) {
+    return new Promise(resolve => queueMicrotask(() => resolve(callback())))
 }
-
-/* <div x-data="{
-    query: '',
-    selected: null,
-    people: [
-        { id: 1, name: 'Kevin' },
-        { id: 2, name: 'Caleb' },
-    ],
-    get filteredPeople() {
-        return this.people.filter(i => {
-            return i.name.toLowerCase().includes(this.query.toLowerCase())
-        })
-    }
-}">
-<p x-text="query"></p>
-<div class="fixed top-16 w-72">
-    <div x-combobox x-model="selected">
-            <div class="relative mt-1">
-                <div class="relative w-full cursor-default overflow-hidden rounded-lg bg-white text-left shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-teal-300 sm:text-sm">
-                    <input x-combobox:input class="w-full border-none py-2 pl-3 pr-10 text-sm leading-5 text-gray-900 focus:ring-0" :display-value="() => (person) => person.name" @change="query = $event.target.value" />
-                    <button x-combobox:button class="absolute inset-y-0 right-0 flex items-center pr-2">
-                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="h-5 w-5 text-gray-400"><path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
-                    </button>
-                </div>
-                <ul x-combobox:options class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
-                    <div x-show="filteredPeople.length === 0 && query !== ''" class="relative cursor-default select-none py-2 px-4 text-gray-700">
-                        Nothing found.
-                    </div>
-
-                    <template x-for="person in filteredPeople" :key="person.id">
-                        <li x-combobox:option :value="person" class="relative cursor-default select-none py-2 pl-10 pr-4" :class="{ 'bg-teal-600 text-white': $comboboxOption.active, 'text-gray-900': !$comboboxOption.active, }">
-                            <span x-text="person.name" class="block truncate" :class="{ 'font-medium': $comboboxOption.selected, 'font-normal': ! $comboboxOption.selected }"></span>
-
-                            <template x-if="$comboboxOption.selected">
-                                <span class="absolute inset-y-0 left-0 flex items-center pl-3" :class="{ 'text-white': $comboboxOption.active, 'text-teal-600': !$comboboxOption.active }">
-                                    <CheckIcon class="h-5 w-5" aria-hidden="true" />
-                                </span>
-                            </template>
-                        </li>
-                    </template>
-                </ul>
-            </div>
-        </div>
-    </div>
-</div> */

+ 9 - 5
packages/ui/src/disclosure.js

@@ -25,12 +25,13 @@ function handleRoot(el, Alpine) {
         'x-modelable': '__isOpen',
         'x-data'() {
             return {
-                init() {
-                    queueMicrotask(() => {
-                         let defaultIsOpen = Boolean(Alpine.bound(this.$el, 'default-open', false))
+                // The panel will call this...
+                // We can't do this inside a microtask in x-init because, when default-open is set to "true",
+                // It will cause the panel to transition in for the first time, instead of showing instantly...
+                __determineDefaultOpenState() {
+                    let defaultIsOpen = Boolean(Alpine.bound(this.$el, 'default-open', false))
 
-                         if (defaultIsOpen) this.__isOpen = defaultIsOpen
-                    })
+                    if (defaultIsOpen) this.__isOpen = defaultIsOpen
                 },
                 __isOpen: false,
                 __close() {
@@ -70,6 +71,9 @@ function handleButton(el, Alpine) {
 
 function handlePanel(el, Alpine) {
     Alpine.bind(el, {
+        'x-init'() {
+            this.$data.__determineDefaultOpenState()
+        },
         'x-show'() {
             return this.$data.__isOpen
         },

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

@@ -1,3 +1,4 @@
+import combobox from './combobox'
 import dialog from './dialog'
 import disclosure from './disclosure'
 import listbox from './listbox'
@@ -8,6 +9,7 @@ import radio from './radio'
 import tabs from './tabs'
 
 export default function (Alpine) {
+    combobox(Alpine)
     dialog(Alpine)
     disclosure(Alpine)
     listbox(Alpine)

+ 227 - 225
packages/ui/src/list-context.js

@@ -1,141 +1,177 @@
-import Alpine from "../../alpinejs/src/alpine"
 
-export function generateContext(multiple, orientation) {
+export function generateContext(Alpine, multiple, orientation, activateSelectedOrFirst) {
     return {
         /**
          * Main state...
          */
-        searchableText: {},
-        disabledKeys: [],
-        activeKey: null,
-        selectedKeys: [],
+        items: [],
+        activeKey: switchboard(),
         orderedKeys: [],
-        elsByKey: {},
-        values: {},
+        activatedByKeyPress: false,
 
         /**
          *  Initialization...
          */
-        initItem(el, value, disabled) {
-            let key = (Math.random() + 1).toString(36).substring(7)
+        activateSelectedOrFirst: Alpine.debounce(function () {
+            activateSelectedOrFirst(false)
+        }),
 
-            // Register value by key...
-            this.values[key] = value
+        registerItemsQueue: [],
 
-            // Associate key with element...
-            this.elsByKey[key] = el
+        registerItem(key, el, value, disabled) {
+            // We need to queue up these additions to not slow down the
+            // init process for each row...
+            if (this.registerItemsQueue.length === 0) {
+                queueMicrotask(() => {
+                    if (this.registerItemsQueue.length > 0) {
+                        this.items = this.items.concat(this.registerItemsQueue)
 
-            // Register key for ordering...
-            this.orderedKeys.push(key)
+                        this.registerItemsQueue = []
 
-            // Register key for searching...
-            this.searchableText[key] = el.textContent.trim().toLowerCase()
+                        this.reorderKeys()
+                        this.activateSelectedOrFirst()
+                    }
+                })
+            }
 
-            // Store whether disabled or not...
-            disabled && this.disabledKeys.push(key)
+            let item = {
+                key, el, value, disabled
+            }
 
-            return key
+            this.registerItemsQueue.push(item)
         },
 
-        destroyItem(el) {
-            let key = keyByValue(this.elsByKey, el)
+        unregisterKeysQueue: [],
 
-            delete this.values[key]
-            delete this.elsByKey[key]
-            delete this.orderedKeys[this.orderedKeys.indexOf(key)]
-            delete this.searchableText[key]
-            delete this.disabledKeys[key]
+        unregisterItem(key) {
+            // This gets triggered when the mutation observer picks up DOM changes.
+            // It will get called for every row that gets removed. If there are
+            // 1000x rows, we want to trigger this cleanup when the first one
+            // is handled, let the others add their keys to the queue, then
+            // handle all the cleanup in bulk at the end. Big perf gain...
+            if (this.unregisterKeysQueue.length === 0) {
+                queueMicrotask(() => {
+                    if (this.unregisterKeysQueue.length > 0) {
+                        this.items = this.items.filter(i => ! this.unregisterKeysQueue.includes(i.key))
+                        this.orderedKeys = this.orderedKeys.filter(i => ! this.unregisterKeysQueue.includes(i))
 
-            this.reorderKeys()
-        },
-
-        /**
-         * Handle elements...
-         */
-         reorderKeys() {
-            // Filter out elements removed from the dom...
-            this.orderedKeys.forEach((key) => {
-                let el = this.elsByKey[key]
+                        this.unregisterKeysQueue = []
 
-                if (el.isConnected) return
+                        this.reorderKeys()
+                        this.activateSelectedOrFirst()
+                    }
+                })
+            }
 
-                this.destroyItem(el)
-            })
+            this.unregisterKeysQueue.push(key)
+        },
 
-            this.orderedKeys = this.orderedKeys.slice().sort((a, z) => {
-                if (a === null || z === null) return 0
+        getItemByKey(key) {
+            return this.items.find(i => i.key === key)
+        },
 
-                let aEl = this.elsByKey[a]
-                let zEl = this.elsByKey[z]
+        getItemByValue(value) {
+            return this.items.find(i => Alpine.raw(i.value) === Alpine.raw(value))
+        },
 
-                let position = aEl.compareDocumentPosition(zEl)
+        getItemByEl(el) {
+            return this.items.find(i => i.el === el)
+        },
 
+        getItemsByValues(values) {
+            let rawValues = values.map(i => Alpine.raw(i));
+            let filteredValue = this.items.filter(i => rawValues.includes(Alpine.raw(i.value)))
+            filteredValue = filteredValue.slice().sort((a, b) => {
+                let position = a.el.compareDocumentPosition(b.el)
                 if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
                 if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
                 return 0
             })
+            return filteredValue
         },
 
-        activeEl() {
-            if (! this.activeKey) return
+        getActiveItem() {
+            if (! this.hasActive()) return null
 
-            return this.elsByKey[this.activeKey]
-        },
+            let item = this.items.find(i => i.key === this.activeKey.get())
 
-        isActiveEl(el) {
-            let key = keyByValue(this.elsByKey, el)
+            if (! item) this.deactivateKey(this.activeKey.get())
 
-            if (! key) return
+            return item
+        },
+
+        activateItem(item) {
+            if (! item) return
 
-            return this.activeKey === key
+            this.activateKey(item.key)
         },
 
-        activateEl(el) {
-            let key = keyByValue(this.elsByKey, el)
+        /**
+         * Handle elements...
+         */
+         reorderKeys: Alpine.debounce(function () {
+            this.orderedKeys = this.items.map(i => i.key)
 
-            if (! key) return
+            this.orderedKeys = this.orderedKeys.slice().sort((a, z) => {
+                if (a === null || z === null) return 0
 
-            this.activateKey(key)
-        },
+                let aEl = this.items.find(i => i.key === a).el
+                let zEl = this.items.find(i => i.key === z).el
 
-        selectEl(el) {
-            let key = keyByValue(this.elsByKey, el)
+                let position = aEl.compareDocumentPosition(zEl)
 
-            if (! key) return
+                if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1
+                if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1
+                return 0
+            })
+
+            // If there no longer is the active key in the items list, then
+            // deactivate it...
+            if (! this.orderedKeys.includes(this.activeKey.get())) this.deactivateKey(this.activeKey.get())
+        }),
 
-            this.selectKey(key)
+        getActiveKey() {
+            return this.activeKey.get()
         },
 
-        isSelectedEl(el) {
-            let key = keyByValue(this.elsByKey, el)
+        activeEl() {
+            if (! this.activeKey.get()) return
 
-            if (! key) return
+            return this.items.find(i => i.key === this.activeKey.get()).el
+        },
 
-            return this.isSelected(key)
+        isActiveEl(el) {
+            let key = this.items.find(i => i.el === el)
+
+            return this.activeKey.is(key)
         },
 
-        isDisabledEl(el) {
-            let key = keyByValue(this.elsByKey, el)
+        activateEl(el) {
+            let item = this.items.find(i => i.el === el)
 
-            if (! key) return
+            this.activateKey(item.key)
+        },
 
-            return this.isDisabled(key)
+        isDisabledEl(el) {
+            return this.items.find(i => i.el === el).disabled
         },
 
         get isScrollingTo() { return this.scrollingCount > 0 },
 
         scrollingCount: 0,
 
-        activateAndScrollToKey(key) {
+        activateAndScrollToKey(key, activatedByKeyPress) {
+            if (! this.getItemByKey(key)) return
+
             // 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)
+            this.activateKey(key, activatedByKeyPress)
 
-            let targetEl = this.elsByKey[key]
+            let targetEl = this.items.find(i => i.key === key).el
 
             targetEl.scrollIntoView({ block: 'nearest' })
 
@@ -148,176 +184,72 @@ export function generateContext(multiple, orientation) {
         },
 
         /**
-         * Handle values...
+         * Handle disabled keys...
          */
-        selectedValueOrValues() {
-            if (multiple) {
-                return this.selectedValues()
-            } else {
-                return this.selectedValue()
-            }
-        },
-
-        selectedValues() {
-            return this.selectedKeys.map(i => this.values[i])
-        },
+        isDisabled(key) {
+            let item = this.items.find(i => i.key === key)
 
-        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 (! item) return false
 
-            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)
-                    }
-                }
-            }
+            return item.disabled
         },
 
-        /**
-         * Handle disabled keys...
-         */
-        isDisabled(key) { return this.disabledKeys.includes(key) },
-
         get nonDisabledOrderedKeys() {
             return this.orderedKeys.filter(i => ! this.isDisabled(i))
         },
 
         /**
-         * Handle selected keys...
+         * Handle activated 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] },
+        hasActive() { return !! this.activeKey.get() },
 
         /**
-         * Handle activated keys...
+         * Return true if the latest active element was activated
+         * by the user (i.e. using the arrow keys) and false if was
+         * activated automatically by alpine (i.e. first element automatically
+         * activated after filtering the list)
          */
-        hasActive() { return !! this.activeKey },
-
-        isActiveKey(key) { return this.activeKey === key },
+        wasActivatedByKeyPress() {return this.activatedByKeyPress},
 
-        get active() { return this.hasActive() && this.values[this.activeKey] },
+        isActiveKey(key) { return this.activeKey.is(key) },
 
-        activateSelectedOrFirst() {
-            let firstSelected = this.firstSelectedKey()
-
-            if (firstSelected) {
-                return this.activateKey(firstSelected)
-            }
-
-            let firstKey = this.firstKey()
+        activateKey(key, activatedByKeyPress = false) {
+            if (this.isDisabled(key)) return
 
-            if (firstKey) {
-                this.activateKey(firstKey)
-            }
+            this.activeKey.set(key)
+            this.activatedByKeyPress = activatedByKeyPress
         },
 
-        activateKey(key) {
-            if (this.isDisabled(key)) return
-
-            this.activeKey = key
+        deactivateKey(key) {
+            if (this.activeKey.get() === key) {
+                this.activeKey.set(null)
+                this.activatedByKeyPress = false
+            }
         },
 
         deactivate() {
-            if (! this.activeKey) return
+            if (! this.activeKey.get()) return
             if (this.isScrollingTo) return
 
-            this.activeKey = null
+            this.activeKey.set(null)
+            this.activatedByKeyPress = false
         },
 
         /**
-         * Handle active key traveral...
+         * Handle active key traversal...
          */
         nextKey() {
-            if (! this.activeKey) return
+            if (! this.activeKey.get()) return
 
-            let index = this.nonDisabledOrderedKeys.findIndex(i => i === this.activeKey)
+            let index = this.nonDisabledOrderedKeys.findIndex(i => i === this.activeKey.get())
 
             return this.nonDisabledOrderedKeys[index + 1]
         },
 
         prevKey() {
-            if (! this.activeKey) return
+            if (! this.activeKey.get()) return
 
-            let index = this.nonDisabledOrderedKeys.findIndex(i => i === this.activeKey)
+            let index = this.nonDisabledOrderedKeys.findIndex(i => i === this.activeKey.get())
 
             return this.nonDisabledOrderedKeys[index - 1]
         },
@@ -337,11 +269,11 @@ export function generateContext(multiple, orientation) {
 
             let foundKey
 
-            for (let key in this.searchableText) {
-                let content = this.searchableText[key]
+            for (let key in this.items) {
+                let content = this.items[key].el.textContent.trim().toLowerCase()
 
                 if (content.startsWith(this.searchQuery)) {
-                    foundKey = key
+                    foundKey = this.items[key].key
                     break;
                 }
             }
@@ -351,51 +283,75 @@ export function generateContext(multiple, orientation) {
             return foundKey
         },
 
-        activateByKeyEvent(e) {
-            this.reorderKeys()
+        activateByKeyEvent(e, searchable = false, isOpen = () => false, open = () => {}, setIsTyping) {
+            let targetKey, hasActive
 
-            let hasActive = this.hasActive()
+            setIsTyping(true)
 
-            let targetKey
+            let activatedByKeyPress = true
 
             switch (e.key) {
-                case 'Tab':
-                case 'Backspace':
-                case 'Delete':
-                case 'Meta':
-                    break;
-
-                    break;
+                // case 'Backspace':
+                // case 'Delete':
                 case ['ArrowDown', 'ArrowRight'][orientation === 'vertical' ? 0 : 1]:
                     e.preventDefault(); e.stopPropagation()
+
+                    setIsTyping(false)
+
+                    if (! isOpen()) {
+                        open()
+                        break;
+                    }
+
+                    this.reorderKeys(); hasActive = this.hasActive()
+
                     targetKey = hasActive ? this.nextKey() : this.firstKey()
                     break;
 
                 case ['ArrowUp', 'ArrowLeft'][orientation === 'vertical' ? 0 : 1]:
                     e.preventDefault(); e.stopPropagation()
+
+                    setIsTyping(false)
+
+                    if (! isOpen()) {
+                        open()
+                        break;
+                    }
+
+                    this.reorderKeys(); hasActive = this.hasActive()
+
                     targetKey = hasActive ? this.prevKey() : this.lastKey()
                     break;
                 case 'Home':
                 case 'PageUp':
+                    if (e.key == 'Home' && e.shiftKey) return;
+
                     e.preventDefault(); e.stopPropagation()
+                    setIsTyping(false)
+                    this.reorderKeys(); hasActive = this.hasActive()
                     targetKey = this.firstKey()
                     break;
 
                 case 'End':
                 case 'PageDown':
+                    if (e.key == 'End' && e.shiftKey) return;
+
                     e.preventDefault(); e.stopPropagation()
+                    setIsTyping(false)
+                    this.reorderKeys(); hasActive = this.hasActive()
                     targetKey = this.lastKey()
                     break;
 
                 default:
-                    if (e.key.length === 1) {
+                    activatedByKeyPress = this.activatedByKeyPress
+                    if (searchable && e.key.length === 1) {
                         targetKey = this.searchKey(e.key)
                     }
                     break;
             }
 
             if (targetKey) {
-                this.activateAndScrollToKey(targetKey)
+                this.activateAndScrollToKey(targetKey, activatedByKeyPress)
             }
         }
     }
@@ -405,7 +361,7 @@ function keyByValue(object, value) {
     return Object.keys(object).find(key => object[key] === value)
 }
 
-export function renderHiddenInputs(el, name, value) {
+export function renderHiddenInputs(Alpine, el, name, value) {
     // Create input elements...
     let newInputs = generateInputs(name, value)
 
@@ -458,3 +414,49 @@ function generateInputs(name, value, carry = []) {
 function isObjectOrArray(subject) {
     return typeof subject === 'object' && subject !== null
 }
+
+function switchboard(value) {
+    let lookup = {}
+
+    let current
+
+    let changeTracker = Alpine.reactive({ state: false })
+
+    let get = () => {
+        // Depend on the change tracker so reading "get" becomes reactive...
+        if (changeTracker.state) {
+            //
+        }
+
+        return current
+    }
+
+    let set = (newValue) => {
+        if (newValue === current) return
+
+        if (current !== undefined) lookup[current].state = false
+
+        current = newValue
+
+        if (lookup[newValue] === undefined) {
+            lookup[newValue] = Alpine.reactive({ state: true })
+        } else {
+            lookup[newValue].state = true
+        }
+
+        changeTracker.state = ! changeTracker.state
+    }
+
+    let is = (comparisonValue) => {
+        if (lookup[comparisonValue] === undefined) {
+            lookup[comparisonValue] = Alpine.reactive({ state: false })
+            return lookup[comparisonValue].state
+        }
+
+        return !! lookup[comparisonValue].state
+    }
+
+    value === undefined || set(value)
+
+    return { get, set, is }
+}

+ 221 - 90
packages/ui/src/listbox.js

@@ -12,25 +12,35 @@ export default function (Alpine) {
     Alpine.magic('listbox', (el) => {
         let data = Alpine.$data(el)
 
-        if (! data.__ready) return {
-            isDisabled: false,
-            isOpen: false,
-            selected: null,
-            active: null,
-        }
-
         return {
+            // @deprecated:
+            get selected() {
+                return data.__value
+            },
+            // @deprecated:
+            get active() {
+                let active = data.__context.getActiveItem()
+
+                return active && active.value
+            },
+            get value() {
+                return data.__value
+            },
             get isOpen() {
                 return data.__isOpen
             },
             get isDisabled() {
                 return data.__isDisabled
             },
-            get selected() {
-                return data.__value
+            get activeOption() {
+                let active = data.__context.getActiveItem()
+
+                return active && active.value
             },
-            get active() {
-                return data.__context.active
+            get activeIndex() {
+                let active = data.__context.getActiveItem()
+
+                return active && active.key
             },
         }
     })
@@ -38,29 +48,19 @@ export default function (Alpine) {
     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
+        if (! optionEl) throw 'No x-combobox:option directive found...'
 
         return {
             get isActive() {
-                return context.isActiveEl(optionEl)
+                return data.__context.isActiveKey(optionEl.__optionKey)
             },
             get isSelected() {
-                return context.isSelectedEl(optionEl)
+                return data.__isSelected(optionEl)
             },
             get isDisabled() {
-                return context.isDisabledEl(optionEl)
+                return data.__context.isDisabled(optionEl.__optionKey)
             },
         }
     })
@@ -68,10 +68,16 @@ export default function (Alpine) {
 
 function handleRoot(el, Alpine) {
     Alpine.bind(el, {
+        // Setup...
         'x-id'() { return ['alpine-listbox-button', 'alpine-listbox-options', 'alpine-listbox-label'] },
         'x-modelable': '__value',
+
+        // Initialize...
         'x-data'() {
             return {
+                /**
+                 * Listbox state...
+                 */
                 __ready: false,
                 __value: null,
                 __isOpen: false,
@@ -82,60 +88,60 @@ function handleRoot(el, Alpine) {
                 __compareBy: null,
                 __inputName: null,
                 __orientation: 'vertical',
+                __hold: false,
+
+                /**
+                 * Comobox initialization...
+                 */
                 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.__isMultiple = Alpine.extractProp(el, 'multiple', false)
+                    this.__isDisabled = Alpine.extractProp(el, 'disabled', false)
+                    this.__inputName = Alpine.extractProp(el, 'name', null)
+                    this.__compareBy = Alpine.extractProp(el, 'by')
+                    this.__orientation = Alpine.extractProp(el, 'horizontal', false) ? 'horizontal' : 'vertical'
 
-                    this.__context = generateContext(this.__isMultiple, this.__orientation)
+                    this.__context = generateContext(Alpine, this.__isMultiple, this.__orientation, () => this.$data.__activateSelectedOrFirst())
 
-                    let defaultValue = Alpine.bound(el, 'default-value', null)
+                    let defaultValue = Alpine.extractProp(el, 'default-value', this.__isMultiple ? [] : 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".
+                    // 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.__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)
-                            })
+                        Alpine.effect(() => {
+                            // Everytime the value changes, we need to re-render the hidden inputs,
+                            // if a user passed the "name" prop...
+                            this.__inputName && renderHiddenInputs(Alpine, this.$el, this.__inputName, this.__value)
+                        })
+
+                        // Keep the currently selected value in sync with the input value...
+                        Alpine.effect(() => {
+                            this.__resetInput()
                         })
                     })
                 },
+                __resetInput() {
+                    let input = this.$refs.__input
+                    if (! input) return
+
+                    let value = this.$data.__getCurrentValue()
+
+                    input.value = value
+                },
+                __getCurrentValue() {
+                    if (! this.$refs.__input) return ''
+                    if (! this.__value) return ''
+                    if (this.$data.__displayValue && this.__value !== undefined) return this.$data.__displayValue(this.__value)
+                    if (typeof this.__value === 'string') return this.__value
+                    return ''
+                },
                 __open() {
+                    if (this.__isOpen) return
                     this.__isOpen = true
 
-                    this.__context.activateSelectedOrFirst()
+                    this.__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
@@ -146,8 +152,85 @@ function handleRoot(el, Alpine) {
                 __close() {
                     this.__isOpen = false
 
+                    this.__context.deactivate()
+
                     this.$nextTick(() => this.$refs.__button.focus({ preventScroll: true }))
-                }
+                },
+                __activateSelectedOrFirst(activateSelected = true) {
+                    if (! this.__isOpen) return
+
+                    if (this.__context.getActiveKey()) {
+                        this.__context.activateAndScrollToKey(this.__context.getActiveKey())
+                        return
+                    }
+
+                    let firstSelectedValue
+
+                    if (this.__isMultiple) {
+                        firstSelectedValue = this.__value.find(i => {
+                            return !! this.__context.getItemByValue(i)
+                        })
+                    } else {
+                        firstSelectedValue = this.__value
+                    }
+
+                    if (activateSelected && firstSelectedValue) {
+                        let firstSelected = this.__context.getItemByValue(firstSelectedValue)
+
+                        firstSelected && this.__context.activateAndScrollToKey(firstSelected.key)
+                    } else {
+                        this.__context.activateAndScrollToKey(this.__context.firstKey())
+                    }
+                },
+                __selectActive() {
+                    let active = this.$data.__context.getActiveItem()
+                    if (active) this.__toggleSelected(active.value)
+                },
+                __selectOption(el) {
+                    let item = this.__context.getItemByEl(el)
+
+                    if (item) this.__toggleSelected(item.value)
+                },
+                __isSelected(el) {
+                    let item = this.__context.getItemByEl(el)
+
+                    if (! item) return false
+                    if (! item.value) return false
+
+                    return this.__hasSelected(item.value)
+                },
+                __toggleSelected(value) {
+                    if (! this.__isMultiple) {
+                        this.__value = value
+
+                        return
+                    }
+
+                    let index = this.__value.findIndex(j => this.__compare(j, value))
+
+                    if (index === -1) {
+                        this.__value.push(value)
+                    } else {
+                        this.__value.splice(index, 1)
+                    }
+                },
+                __hasSelected(value) {
+                    if (! this.__isMultiple) return this.__compare(this.__value, value)
+
+                    return this.__value.some(i => this.__compare(i, value))
+                },
+                __compare(a, b) {
+                    let by = this.__compareBy
+
+                    if (! by) by = (a, b) => Alpine.raw(a) === Alpine.raw(b)
+
+                    if (typeof by === 'string') {
+                        let property = by
+                        by = (a, b) => a[property] === b[property]
+                    }
+
+                    return by(a, b)
+                },
             }
         },
     })
@@ -163,13 +246,20 @@ function handleLabel(el, Alpine) {
 
 function handleButton(el, Alpine) {
     Alpine.bind(el, {
+        // Setup...
         'x-ref': '__button',
         ':id'() { return this.$id('alpine-listbox-button') },
+
+        // Accessibility attributes...
         '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') },
+
+        // Initialize....
         'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && !this.$el.hasAttribute('type')) this.$el.type = 'button' },
+
+        // Register listeners...
         '@click'() { this.$data.__open() },
         '@keydown'(e) {
             if (['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
@@ -186,31 +276,49 @@ function handleButton(el, Alpine) {
 
 function handleOptions(el, Alpine) {
     Alpine.bind(el, {
+        // Setup...
         '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',
+
+        // Accessibility attributes...
         'role': 'listbox',
+        tabindex: '0',
         ':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() },
+        ':aria-activedescendant'() {
+            if (! this.$data.__context.hasActive()) return
+
+            let active = this.$data.__context.getActiveItem()
+
+            return active ? active.el.id : null
+        },
+
+        // Initialize...
+        'x-init'() {
+            this.$data.__isStatic = Alpine.extractProp(this.$el, 'static', false)
+
+            if (Alpine.bound(this.$el, 'hold')) {
+                this.$data.__hold = true;
+            }
+        },
+
+        'x-show'() { return this.$data.__isStatic ? true : this.$data.__isOpen },
         'x-trap'() { return this.$data.__isOpen },
-        '@keydown'(e) { this.__context.activateByKeyEvent(e) },
+        '@click.outside'() { this.$data.__close() },
+        '@keydown.escape.stop.prevent'() { this.$data.__close() },
+        '@focus'() { this.$data.__activateSelectedOrFirst() },
+        '@keydown'(e) {
+            queueMicrotask(() => this.$data.__context.activateByKeyEvent(e, true, () => this.$data.__isOpen, () => this.$data.__open(), () => {}))
+         },
         '@keydown.enter.stop.prevent'() {
-            this.__context.selectActive();
+            this.$data.__selectActive();
 
             this.$data.__isMultiple || this.$data.__close()
         },
         '@keydown.space.stop.prevent'() {
-            this.__context.selectActive();
+            this.$data.__selectActive();
 
             this.$data.__isMultiple || this.$data.__close()
         },
@@ -220,25 +328,48 @@ function handleOptions(el, Alpine) {
 function handleOption(el, Alpine) {
     Alpine.bind(el, () => {
         return {
+            'x-id'() { return ['alpine-listbox-option'] },
             ':id'() { return this.$id('alpine-listbox-option') },
-            ':tabindex'() { return this.$listbox.isDisabled ? false : '-1' },
+
+            // Accessibility attributes...
             'role': 'option',
-            'x-init'() {
-                queueMicrotask(() => {
-                    let value = Alpine.bound(el, 'value')
-                    let disabled = Alpine.bound(el, 'disabled')
+            ':tabindex'() { return this.$listboxOption.isDisabled ? false : '-1' },
+            ':aria-selected'() { return this.$listboxOption.isSelected },
+
+            // Initialize...
+            'x-data'() {
+                return {
+                    init() {
+                        let key = el.__optionKey = (Math.random() + 1).toString(36).substring(7)
+
+                        let value = Alpine.extractProp(el, 'value')
+                        let disabled = Alpine.extractProp(el, 'disabled', false, false)
 
-                    el.__optionKey = this.$data.__context.initItem(el, value, disabled)
-                })
+                        this.$data.__context.registerItem(key, el, value, disabled)
+                    },
+                    destroy() {
+                        this.$data.__context.unregisterItem(this.$el.__optionKey)
+                    },
+                }
             },
-            ':aria-selected'() { return this.$listboxOption.isSelected },
+
+            // Register listeners...
             '@click'() {
                 if (this.$listboxOption.isDisabled) return;
-                this.$data.__context.selectEl(el);
+
+                this.$data.__selectOption(el)
+
                 this.$data.__isMultiple || this.$data.__close()
             },
-            '@mousemove'() { this.$data.__context.activateEl(el) },
-            '@mouseleave'() { this.$data.__context.deactivate() },
+            '@mouseenter'() { this.$data.__context.activateEl(el) },
+            '@mouseleave'() {
+                this.$data.__hold || this.$data.__context.deactivate()
+            },
         }
     })
 }
+
+// Little utility to defer a callback into the microtask queue...
+function microtask(callback) {
+    return new Promise(resolve => queueMicrotask(() => resolve(callback())))
+}

+ 18 - 7
packages/ui/src/menu.js

@@ -14,7 +14,7 @@ export default function (Alpine) {
                 return $data.__activeEl == $data.__itemEl
             },
             get isDisabled() {
-                return el.__isDisabled.value
+                return $data.__itemEl.__isDisabled.value
             },
         }
     })
@@ -29,14 +29,19 @@ function handleRoot(el, Alpine) {
                 __itemEls: [],
                 __activeEl: null,
                 __isOpen: false,
-                __open() {
+                __open(activationStrategy) {
                     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 }))
+                    nextTick(() => {
+                        this.$refs.__items.focus({ preventScroll: true })
+
+                        // Activate the first item every time the menu is open...
+                        activationStrategy && activationStrategy(Alpine, this.$refs.__items, el => el.__activate())
+                    })
                 },
                 __close(focusAfter = true) {
                     this.__isOpen = false
@@ -67,12 +72,18 @@ function handleButton(el, Alpine) {
         '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.up.stop.prevent'() { this.$data.__open(dom.last) },
         '@keydown.space.stop.prevent'() { this.$data.__open() },
         '@keydown.enter.stop.prevent'() { this.$data.__open() },
     })
 }
 
+// When patching children:
+// The child isn't initialized until it is reached. This is normally fine
+// except when something like this happens where an "id" is added during the initializing phase
+// because the "to" element hasn't initialized yet, it doesn't have the ID, so there is a "key" mismatch
+
+
 function handleItems(el, Alpine) {
     Alpine.bind(el, {
         'x-ref': '__items',
@@ -153,10 +164,10 @@ function handleItem(el, Alpine) {
             },
             'x-id'() { return ['alpine-menu-item'] },
             ':id'() { return this.$id('alpine-menu-item') },
-            ':tabindex'() { return this.$el.__isDisabled.value ? false : '-1' },
+            ':tabindex'() { return this.__itemEl.__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() },
+            '@mousemove'() { this.__itemEl.__isDisabled.value || this.$menuItem.isActive || this.__itemEl.__activate() },
+            '@mouseleave'() { this.__itemEl.__isDisabled.value || ! this.$menuItem.isActive || this.__itemEl.__deactivate() },
         }
     })
 }

+ 3 - 4
scripts/build.js

@@ -6,14 +6,15 @@ let zlib = require('zlib');
     // Packages:
     'alpinejs',
     'csp',
-    'history',
+    // 'history', - removed because this plugin has been moved to livewire/livewire until it's stable...
+    // 'navigate', - remove because this plugin has been moved to livewire/livewire until it's stable...
     'intersect',
     'persist',
     'collapse',
+    'anchor',
     'morph',
     'focus',
     'mask',
-    'navigate',
     'ui',
 ]).forEach(package => {
     if (! fs.existsSync(`./packages/${package}/dist`)) {
@@ -61,7 +62,6 @@ function bundleFile(package, file) {
                 entryPoints: [`packages/${package}/builds/${file}`],
                 outfile: `packages/${package}/dist/${file.replace('.js', '.esm.js')}`,
                 bundle: true,
-                packages: 'external',
                 platform: 'neutral',
                 mainFields: ['module', 'main'],
             })
@@ -70,7 +70,6 @@ function bundleFile(package, file) {
                 entryPoints: [`packages/${package}/builds/${file}`],
                 outfile: `packages/${package}/dist/${file.replace('.js', '.cjs.js')}`,
                 bundle: true,
-                packages: 'external',
                 target: ['node10.4'],
                 platform: 'node',
             }).then(() => {

+ 12 - 0
scripts/release.js

@@ -39,6 +39,9 @@ function writeNewAlpineVersion() {
     writeToPackageDotJson('alpinejs', 'version', version)
     console.log('Bumping alpinejs package.json: '+version)
 
+    writeToPackageDotJson('csp', 'version', version)
+    console.log('Bumping @alpinejs/csp package.json: '+version)
+
     writeToPackageDotJson('intersect', 'version', version)
     console.log('Bumping @alpinejs/intersect package.json: '+version)
 
@@ -51,6 +54,9 @@ function writeNewAlpineVersion() {
     writeToPackageDotJson('collapse', 'version', version)
     console.log('Bumping @alpinejs/collapse package.json: '+version)
 
+    writeToPackageDotJson('anchor', 'version', version)
+    console.log('Bumping @alpinejs/anchor package.json: '+version)
+
     writeToPackageDotJson('morph', 'version', version)
     console.log('Bumping @alpinejs/morph package.json: '+version)
 
@@ -74,6 +80,9 @@ function publish() {
     console.log('Publishing alpinejs on NPM...');
     runFromPackage('alpinejs', 'npm publish')
 
+    console.log('Publishing @alpinejs/csp on NPM...');
+    runFromPackage('csp', 'npm publish --access public')
+
     console.log('Publishing @alpinejs/docs on NPM...');
     runFromPackage('docs', 'npm publish --access public')
 
@@ -89,6 +98,9 @@ function publish() {
     console.log('Publishing @alpinejs/collapse on NPM...');
     runFromPackage('collapse', 'npm publish --access public')
 
+    console.log('Publishing @alpinejs/anchor on NPM...');
+    runFromPackage('anchor', 'npm publish --access public')
+
     console.log('Publishing @alpinejs/morph on NPM...');
     runFromPackage('morph', 'npm publish --access public')
 

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

@@ -453,6 +453,21 @@ test('Can retrieve Alpine bound data with global bound method',
     }
 )
 
+test('Can extract Alpine bound data as a data prop',
+    html`
+        <div x-data="{ foo: 'bar' }">
+            <div id="1" x-data="{ init() { this.$el.textContent = Alpine.extractProp(this.$el, 'foo') }}" :foo="foo"></div>
+            <div id="2" x-data="{ init() { this.$el.textContent = Alpine.extractProp(this.$el, 'foo', null, false) }}" :foo="foo"></div>
+        </div>
+    `,
+    ({ get }) => {
+        get('#1').should(haveText('bar'))
+        get('#1').should(notHaveAttribute('foo'))
+        get('#2').should(haveText('bar'))
+        get('#2').should(haveAttribute('foo', 'bar'))
+    }
+)
+
 test('x-bind updates checked attribute and property after user interaction',
     html`
         <div x-data="{ checked: true }">

+ 93 - 2
tests/cypress/integration/directives/x-model.spec.js

@@ -1,4 +1,4 @@
-import { haveData, haveText, haveValue, html, test } from '../../utils'
+import { beChecked, haveData, haveText, haveValue, html, notBeChecked, test } from '../../utils'
 
 test('The name of the test',
     html`<h1 x-data x-text="'HEY'"></h1>`,
@@ -79,6 +79,86 @@ test('x-model with number modifier returns: null if empty, original value if cas
     }
 )
 
+test('x-model casts value to boolean initially for radios',
+    html`
+    <div x-data="{ foo: true }">
+        <input id="1" type="radio" value="true" name="foo" x-model.boolean="foo">
+        <input id="2" type="radio" value="false" name="foo" x-model.boolean="foo">
+    </div>
+    `,
+    ({ get }) => {
+        get('div').should(haveData('foo', true))
+        get('#1').should(beChecked())
+        get('#2').should(notBeChecked())
+        get('#2').click()
+        get('div').should(haveData('foo', false))
+        get('#1').should(notBeChecked())
+        get('#2').should(beChecked())
+    }
+)
+
+test('x-model casts value to boolean if boolean modifier is present',
+    html`
+    <div x-data="{ foo: null, bar: null, baz: [] }">
+        <input type="text" x-model.boolean="foo"></input>
+        <input type="checkbox" x-model.boolean="foo"></input>
+        <input type="radio" name="foo" x-model.boolean="foo" value="true"></input>
+        <input type="radio" name="foo" x-model.boolean="foo" value="false"></input>
+        <select x-model.boolean="bar">
+            <option value="true">yes</option>
+            <option value="false">no</option>
+        </select>
+    </div>
+    `,
+    ({ get }) => {
+        get('input[type=text]').type('1')
+        get('div').should(haveData('foo', true))
+        get('input[type=text]').clear().type('0')
+        get('div').should(haveData('foo', false))
+
+        get('input[type=checkbox]').check()
+        get('div').should(haveData('foo', true))
+        get('input[type=checkbox]').uncheck()
+        get('div').should(haveData('foo', false))
+
+        get('input[type=radio][value="true"]').should(notBeChecked())
+        get('input[type=radio][value="false"]').should(beChecked())
+        get('input[type=radio][value="true"]').check()
+        get('div').should(haveData('foo', true))
+        get('input[type=radio][value="false"]').check()
+        get('div').should(haveData('foo', false))
+
+        get('select').select('false')
+        get('div').should(haveData('bar', false))
+        get('select').select('true')
+        get('div').should(haveData('bar', true))
+    }
+)
+
+test('x-model with boolean modifier returns: null if empty, original value if casting fails, numeric value if casting passes',
+    html`
+    <div x-data="{ foo: 0, bar: '' }">
+        <input x-model.boolean="foo"></input>
+    </div>
+    `,
+    ({ get }) => {
+        get('input').clear()
+        get('div').should(haveData('foo', null))
+        get('input').clear().type('bar')
+        get('div').should(haveData('foo', true))
+        get('input').clear().type('1')
+        get('div').should(haveData('foo', true))
+        get('input').clear().type('1').clear()
+        get('div').should(haveData('foo', null))
+        get('input').clear().type('0')
+        get('div').should(haveData('foo', false))
+        get('input').clear().type('bar')
+        get('div').should(haveData('foo', true))
+        get('input').clear().type('0').clear()
+        get('div').should(haveData('foo', null))
+    }
+)
+
 test('x-model trims value if trim modifier is present',
     html`
     <div x-data="{ foo: '' }">
@@ -186,5 +266,16 @@ test('x-model with fill modifier respects number modifier',
     }
 );
 
-
+test(
+    'x-model with fill applies on checkboxes bound to array',
+    html`
+        <div x-data="{ a: ['456'] }">
+            <input type="checkbox" x-model.fill="a" value="123" checked />
+            <input type="checkbox" x-model.fill="a" value="456" />
+        </div>
+    `,
+    ({ get }) => {
+        get('[x-data]').should(haveData('a', ['123']));
+    }
+);
 

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

@@ -585,6 +585,20 @@ test('.dot modifier correctly binds event listener',
         get('span').should(haveText('baz'))
     }
 )
+test('underscores are allowed in event names',
+    html`
+        <div x-data="{ foo: 'bar' }" x-on:event_name="foo = 'baz'">
+            <button x-on:click="$dispatch('event_name')"></button>
+
+            <span x-text="foo"></span>
+        </div>
+    `,
+    ({ get }) => {
+        get('span').should(haveText('bar'))
+        get('button').click()
+        get('span').should(haveText('baz'))
+    }
+)
 
 test('.dot modifier correctly binds event listener with namespace',
     html`

+ 35 - 0
tests/cypress/integration/entangle.spec.js

@@ -69,3 +69,38 @@ test.skip('can release entanglement',
         get('input[outer]').should(haveValue('foobar'))
     }
 )
+
+test(
+    "can handle undefined",
+    [
+        html`
+            <div x-data="{ outer: undefined }">
+                <input x-model="outer" outer />
+
+                <div
+                    x-data="{ inner: 'bar' }"
+                    x-init="() => {}; Alpine.entangle(
+            {
+                get() { return outer },
+                set(value) { outer = value },
+            },
+            {
+                get() { return inner },
+                set(value) { inner = value },
+            }
+        )"
+                >
+                    <input x-model="inner" inner />
+                </div>
+            </div>
+        `,
+    ],
+    ({ get }) => {
+        get("input[outer]").should(haveValue(''));
+        get("input[inner]").should(haveValue(''));
+
+        get("input[inner]").type("bar");
+        get("input[inner]").should(haveValue("bar"));
+        get("input[outer]").should(haveValue("bar"));
+    }
+);

+ 32 - 1
tests/cypress/integration/magics/$id.spec.js

@@ -103,7 +103,7 @@ test('$id scopes can be reset',
             <div x-data>
                 <h1 :id="$id('foo')"></h1>
                 <h5 :id="$id('bar')"></h5>
-                
+
                 <div x-id="['foo']">
                     <h2 :aria-labelledby="$id('foo')"></h2>
                     <h6 :aria-labelledby="$id('bar')"></h6>
@@ -127,3 +127,34 @@ test('$id scopes can be reset',
         get('h6').should(haveAttribute('aria-labelledby', 'bar-1'))
     }
 )
+
+test('can be used with morph without losing track',
+    [html`
+        <div x-data>
+            <p x-id="['foo']">
+                <span :id="$id('foo')">bob</span>
+            </p>
+
+            <h1 :id="$id('bar')">lob</h1>
+        </div>
+    `],
+    ({ get }, reload, window, document) => {
+        let toHtml = html`
+            <div x-data>
+                <p x-id="['foo']">
+                    <span :id="$id('foo')">bob</span>
+                </p>
+
+                <h1 :id="$id('bar')">lob</h1>
+            </div>
+        `
+
+        get('span').should(haveAttribute('id', 'foo-1'))
+        get('h1').should(haveAttribute('id', 'bar-1'))
+
+        get('div').then(([el]) => window.Alpine.morph(el, toHtml))
+
+        get('span').should(haveAttribute('id', 'foo-1'))
+        get('h1').should(haveAttribute('id', 'bar-1'))
+    },
+)

+ 13 - 0
tests/cypress/integration/plugins/anchor.spec.js

@@ -0,0 +1,13 @@
+import { haveAttribute, haveComputedStyle, html, notHaveAttribute, test } from '../../utils'
+
+test('can anchor an element',
+    [html`
+        <div x-data>
+            <button x-ref="foo">toggle</button>
+            <h1 x-anchor="$refs.foo">contents</h1>
+        </div>
+    `],
+    ({ get }, reload) => {
+        get('h1').should(haveComputedStyle('position', 'absolute'))
+    },
+)

+ 1 - 0
tests/cypress/integration/plugins/focus.spec.js

@@ -44,6 +44,7 @@ test('works with clone',
             }
         }">
             <button id="one" @click="open = true">Trap</button>
+
             <div x-trap="open">
                 <input type="text">
                 <button id="two" @click="triggerClone()">Test</button>

+ 219 - 98
tests/cypress/integration/plugins/history.spec.js

@@ -1,100 +1,221 @@
 import { haveText, html, test } from '../../utils'
 
-test('can go back and forth',
-    [html`
-        <div x-data="{ count: $queryString(1) }">
-            <button @click="count++">Inc</button>
-            <span x-text="count"></span>
-        </div>
-    `],
-    ({ get, url, go }) => {
-        get('span').should(haveText('1'))
-        url().should('include', '?count=1')
-        get('button').click()
-        get('span').should(haveText('2'))
-        url().should('include', '?count=2')
-        go('back')
-        get('span').should(haveText('1'))
-        url().should('include', '?count=1')
-        go('forward')
-        get('span').should(haveText('2'))
-        url().should('include', '?count=2')
-    },
-)
-
-test('property is set from the query string on load',
-    [html`
-        <div x-data="{ count: $queryString(1) }">
-            <button @click="count++">Inc</button>
-            <span x-text="count"></span>
-        </div>
-    `],
-    ({ get, url }, reload) => {
-        get('span').should(haveText('1'))
-        url().should('include', '?count=1')
-        get('button').click()
-        get('span').should(haveText('2'))
-        url().should('include', '?count=2')
-        reload()
-        get('span').should(haveText('2'))
-    },
-)
-
-test('can use a query string key alias',
-    [html`
-        <div x-data="{ count: $queryString(1).as('foo') }">
-            <button @click="count++">Inc</button>
-            <span x-text="count"></span>
-        </div>
-    `],
-    ({ get, url }, reload) => {
-        get('span').should(haveText('1'))
-        url().should('include', '?foo=1')
-        get('button').click()
-        get('span').should(haveText('2'))
-        url().should('include', '?foo=2')
-        reload()
-        get('span').should(haveText('2'))
-    },
-)
-
-test('can go back and forth with multiple components',
-    [html`
-        <div x-data="{ foo: $queryString(1) }" id="foo">
-            <button @click="foo++">Inc</button>
-            <span x-text="foo"></span>
-        </div>
-
-        <div x-data="{ bar: $queryString(1) }" id="bar">
-            <button @click="bar++">Inc</button>
-            <span x-text="bar"></span>
-        </div>
-    `],
-    ({ get, url, go }) => {
-        get('#foo span').should(haveText('1'))
-        url().should('include', 'foo=1')
-        get('#foo button').click()
-        get('#foo span').should(haveText('2'))
-        url().should('include', 'foo=2')
-
-        get('#bar span').should(haveText('1'))
-        url().should('include', 'bar=1')
-        get('#bar button').click()
-        get('#bar span').should(haveText('2'))
-        url().should('include', 'bar=2')
-
-        go('back')
-
-        get('#bar span').should(haveText('1'))
-        url().should('include', 'bar=1')
-        get('#foo span').should(haveText('2'))
-        url().should('include', 'foo=2')
-
-        go('back')
-
-        get('#bar span').should(haveText('1'))
-        url().should('include', 'bar=1')
-        get('#foo span').should(haveText('1'))
-        url().should('include', 'foo=1')
-    },
-)
+// Skipping these tests because the plugin has been moved to livewire/livewire until it's stablhese tests because the plugin has been moved to livewire/livewire until it's stable...
+describe.skip('History tests', function () {
+    test('value is reflected in query string upon changing',
+        [html`
+            <div x-data="{ count: $queryString(1) }">
+                <button @click="count++">Inc</button>
+                <h1 @click="count--">Dec</h1>
+                <span x-text="count"></span>
+            </div>
+        `],
+        ({ get, url, go }) => {
+            get('span').should(haveText('1'))
+            url().should('not.include', '?count=1')
+            get('button').click()
+            get('span').should(haveText('2'))
+            url().should('include', '?count=2')
+            get('button').click()
+            get('span').should(haveText('3'))
+            url().should('include', '?count=3')
+            get('h1').click()
+            get('h1').click()
+            get('span').should(haveText('1'))
+            url().should('not.include', '?count=1')
+        },
+    )
+
+    test('can configure always making the query string value present',
+        [html`
+            <div x-data="{ count: $queryString(1).alwaysShow() }">
+                <button @click="count++">Inc</button>
+                <h1 @click="count--">Dec</h1>
+                <span x-text="count"></span>
+            </div>
+        `],
+        ({ get, url, go }) => {
+            get('span').should(haveText('1'))
+            url().should('include', '?count=1')
+            get('button').click()
+            get('span').should(haveText('2'))
+            url().should('include', '?count=2')
+            get('h1').click()
+            get('span').should(haveText('1'))
+            url().should('include', '?count=1')
+        },
+    )
+
+    test('value is persisted across requests',
+        [html`
+            <div x-data="{ count: $queryString(1) }">
+                <button @click="count++">Inc</button>
+                <span x-text="count"></span>
+            </div>
+        `],
+        ({ get, url, go }, reload) => {
+            get('span').should(haveText('1'))
+            url().should('not.include', '?count=1')
+            get('button').click()
+            get('span').should(haveText('2'))
+            url().should('include', '?count=2')
+
+            reload()
+
+            url().should('include', '?count=2')
+            get('span').should(haveText('2'))
+        },
+    )
+
+    test('can provide an alias',
+        [html`
+            <div x-data="{ count: $queryString(1).as('tnuoc') }">
+                <button @click="count++">Inc</button>
+                <span x-text="count"></span>
+            </div>
+        `],
+        ({ get, url, go }) => {
+            get('span').should(haveText('1'))
+            url().should('not.include', '?tnuoc=1')
+            get('button').click()
+            get('span').should(haveText('2'))
+            url().should('include', '?tnuoc=2')
+        },
+    )
+
+    test('can use pushState',
+        [html`
+            <div x-data="{ count: $queryString(1).usePush() }">
+                <button @click="count++">Inc</button>
+                <span x-text="count"></span>
+            </div>
+        `],
+        ({ get, url, go }) => {
+            get('span').should(haveText('1'))
+            url().should('not.include', '?count=1')
+            get('button').click()
+            get('span').should(haveText('2'))
+            url().should('include', '?count=2')
+            go('back')
+            get('span').should(haveText('1'))
+            url().should('not.include', '?count=1')
+            go('forward')
+            get('span').should(haveText('2'))
+            url().should('include', '?count=2')
+        },
+    )
+
+    test('can go back and forth with multiple components',
+        [html`
+            <div x-data="{ foo: $queryString(1).usePush() }" id="foo">
+                <button @click="foo++">Inc</button>
+                <span x-text="foo"></span>
+            </div>
+
+            <div x-data="{ bar: $queryString(1).usePush() }" id="bar">
+                <button @click="bar++">Inc</button>
+                <span x-text="bar"></span>
+            </div>
+        `],
+        ({ get, url, go }) => {
+            get('#foo span').should(haveText('1'))
+            url().should('not.include', 'foo=1')
+            get('#foo button').click()
+            get('#foo span').should(haveText('2'))
+            url().should('include', 'foo=2')
+
+            get('#bar span').should(haveText('1'))
+            url().should('not.include', 'bar=1')
+            get('#bar button').click()
+            get('#bar span').should(haveText('2'))
+            url().should('include', 'bar=2')
+
+            go('back')
+
+            get('#bar span').should(haveText('1'))
+            url().should('not.include', 'bar=1')
+            get('#foo span').should(haveText('2'))
+            url().should('include', 'foo=2')
+
+            go('back')
+
+            get('#bar span').should(haveText('1'))
+            url().should('not.include', 'bar=1')
+            get('#foo span').should(haveText('1'))
+            url().should('not.include', 'foo=1')
+        },
+    )
+
+    test('supports arrays',
+        [html`
+            <div x-data="{ items: $queryString(['foo']) }">
+                <button @click="items.push('bar')">Inc</button>
+                <span x-text="JSON.stringify(items)"></span>
+            </div>
+        `],
+        ({ get, url, go }, reload) => {
+            get('span').should(haveText('["foo"]'))
+            url().should('not.include', '?items')
+            get('button').click()
+            get('span').should(haveText('["foo","bar"]'))
+            url().should('include', '?items[0]=foo&items[1]=bar')
+            reload()
+            url().should('include', '?items[0]=foo&items[1]=bar')
+            get('span').should(haveText('["foo","bar"]'))
+        },
+    )
+
+    test('supports deep arrays',
+        [html`
+            <div x-data="{ items: $queryString(['foo', ['bar', 'baz']]) }">
+                <button @click="items[1].push('bob')">Inc</button>
+                <span x-text="JSON.stringify(items)"></span>
+            </div>
+        `],
+        ({ get, url, go }, reload) => {
+            get('span').should(haveText('["foo",["bar","baz"]]'))
+            url().should('not.include', '?items')
+            get('button').click()
+            get('span').should(haveText('["foo",["bar","baz","bob"]]'))
+            url().should('include', '?items[0]=foo&items[1][0]=bar&items[1][1]=baz&items[1][2]=bob')
+            reload()
+            url().should('include', '?items[0]=foo&items[1][0]=bar&items[1][1]=baz&items[1][2]=bob')
+            get('span').should(haveText('["foo",["bar","baz","bob"]]'))
+        },
+    )
+
+    test('supports objects',
+        [html`
+            <div x-data="{ items: $queryString({ foo: 'bar' }) }">
+                <button @click="items.bob = 'lob'">Inc</button>
+                <span x-text="JSON.stringify(items)"></span>
+            </div>
+        `],
+        ({ get, url, go }, reload) => {
+            get('span').should(haveText('{"foo":"bar"}'))
+            url().should('not.include', '?items')
+            get('button').click()
+            get('span').should(haveText('{"foo":"bar","bob":"lob"}'))
+            url().should('include', '?items[foo]=bar&items[bob]=lob')
+            reload()
+            url().should('include', '?items[foo]=bar&items[bob]=lob')
+            get('span').should(haveText('{"foo":"bar","bob":"lob"}'))
+        },
+    )
+
+    test('encodes values according to RFC 1738 (plus signs for spaces)',
+        [html`
+            <div x-data="{ foo: $queryString('hey&there').alwaysShow(), bar: $queryString('hey there').alwaysShow() }">
+                <span x-text="JSON.stringify(foo)+JSON.stringify(bar)"></span>
+            </div>
+        `],
+        ({ get, url, go }, reload) => {
+            url().should('include', '?foo=hey%26there&bar=hey+there')
+            get('span').should(haveText('"hey&there""hey there"'))
+            reload()
+            url().should('include', '?foo=hey%26there&bar=hey+there')
+            get('span').should(haveText('"hey&there""hey there"'))
+        },
+    )
+})
+

+ 16 - 1
tests/cypress/integration/plugins/mask.spec.js

@@ -60,6 +60,21 @@ test('x-mask with x-model',
     },
 )
 
+test('x-mask with latently bound x-model',
+    [html`
+        <div x-data="{ value: '' }">
+            <input x-mask="(999) 999-9999" x-bind="{ 'x-model': 'value' }" id="1">
+            <input id="2" x-model="value">
+        </div>
+    `],
+    ({ get }) => {
+        get('#1').type('a').should(haveValue('('))
+        get('#2').should(haveValue('('))
+        get('#1').type('1').should(haveValue('(1'))
+        get('#2').should(haveValue('(1'))
+    },
+)
+
 test('x-mask with x-model with initial value',
     [html`
         <div x-data="{ value: '1234567890' }">
@@ -171,7 +186,7 @@ test('$money with different thousands separator',
     }
 );
 
-test('$money works with permenant inserted at beginning',
+test('$money works with permanent inserted at beginning',
     [html`<input x-data x-mask:dynamic="$money">`],
     ({ get }) => {
         get('input').type('40.00').should(haveValue('40.00'))

+ 108 - 12
tests/cypress/integration/plugins/morph.spec.js

@@ -247,6 +247,35 @@ test('can morph using a custom key function',
     },
 )
 
+test('can morph using keys with existing key to be moved up',
+    [html`
+        <ul>
+            <li key="1">foo<input></li>
+            <li key="2">bar<input></li>
+            <li key="3">baz<input></li>
+        </ul>
+    `],
+    ({ get }, reload, window, document) => {
+        let toHtml = html`
+            <ul>
+                <li key="1">foo<input></li>
+                <li key="3">baz<input></li>
+            </ul>
+        `
+
+        get('li:nth-of-type(1) input').type('foo')
+        get('li:nth-of-type(3) input').type('baz')
+
+        get('ul').then(([el]) => window.Alpine.morph(el, toHtml))
+
+        get('li').should(haveLength(2))
+        get('li:nth-of-type(1)').should(haveText('foo'))
+        get('li:nth-of-type(2)').should(haveText('baz'))
+        get('li:nth-of-type(1) input').should(haveValue('foo'))
+        get('li:nth-of-type(2) input').should(haveValue('baz'))
+    },
+)
+
 test('can morph text nodes',
     [html`<h2>Foo <br> Bar</h2>`],
     ({ get }, reload, window, document) => {
@@ -308,6 +337,29 @@ test('can morph using different keys',
     },
 )
 
+test('can morph elements with dynamic ids',
+    [html`
+        <ul>
+            <li x-data x-bind:id="'1'" >foo<input></li>
+        </ul>
+    `],
+    ({ get }, reload, window, document) => {
+        let toHtml = html`
+            <ul>
+                <li x-data x-bind:id="'1'" >foo<input></li>
+            </ul>
+        `
+
+        get('input').type('foo')
+
+        get('ul').then(([el]) => window.Alpine.morph(el, toHtml, {
+            key(el) { return el.id }
+        }))
+
+        get('li:nth-of-type(1) input').should(haveValue('foo'))
+    },
+)
+
 test('can morph different inline nodes',
     [html`
     <div id="from">
@@ -359,19 +411,19 @@ test('can morph table tr',
 test('can morph with conditional markers',
     [html`
         <main>
-            <!-- __BLOCK__ -->
+            <!--[if BLOCK]><![endif]-->
             <div>foo<input></div>
-            <!-- __ENDBLOCK__ -->
+            <!--[if ENDBLOCK]><![endif]-->
             <div>bar<input></div>
         </main>
     `],
     ({ get }, reload, window, document) => {
         let toHtml = html`
         <main>
-            <!-- __BLOCK__ -->
+            <!--[if BLOCK]><![endif]-->
             <div>foo<input></div>
             <div>baz<input></div>
-            <!-- __ENDBLOCK__ -->
+            <!--[if ENDBLOCK]><![endif]-->
             <div>bar<input></div>
         </main>
         `
@@ -390,23 +442,23 @@ test('can morph with conditional markers',
 test('can morph with flat-nested conditional markers',
     [html`
         <main>
-            <!-- __BLOCK__ -->
+            <!--[if BLOCK]><![endif]-->
             <div>foo<input></div>
-            <!-- __BLOCK__ -->
-            <!-- __ENDBLOCK__ -->
-            <!-- __ENDBLOCK__ -->
+            <!--[if BLOCK]><![endif]-->
+            <!--[if ENDBLOCK]><![endif]-->
+            <!--[if ENDBLOCK]><![endif]-->
             <div>bar<input></div>
         </main>
     `],
     ({ get }, reload, window, document) => {
         let toHtml = html`
         <main>
-            <!-- __BLOCK__ -->
+            <!--[if BLOCK]><![endif]-->
             <div>foo<input></div>
-            <!-- __BLOCK__ -->
-            <!-- __ENDBLOCK__ -->
+            <!--[if BLOCK]><![endif]-->
+            <!--[if ENDBLOCK]><![endif]-->
             <div>baz<input></div>
-            <!-- __ENDBLOCK__ -->
+            <!--[if ENDBLOCK]><![endif]-->
             <div>bar<input></div>
         </main>
         `
@@ -441,3 +493,47 @@ test('can morph @event handlers', [
         get('button').should(haveText('buzz'));
     }
 );
+
+test('can morph menu',
+    [html`
+        <main x-data>
+            <article x-menu>
+                <button data-trigger x-menu:button x-text="'ready'"></button>
+
+                <div x-menu:items>
+                    <button x-menu:item href="#edit">
+                        Edit
+                        <input>
+                    </button>
+                </div>
+            </article>
+        </main>
+    `],
+    ({ get }, reload, window, document) => {
+        let toHtml = html`
+            <main x-data>
+                <article x-menu>
+                    <button data-trigger x-menu:button x-text="'ready'"></button>
+
+                    <div x-menu:items>
+                        <button x-menu:item href="#edit">
+                            Edit
+                            <input>
+                        </button>
+                    </div>
+                </article>
+            </main>
+        `
+
+        get('[data-trigger]').should(haveText('ready'));
+        get('button[data-trigger').click()
+
+        get('input').type('foo')
+
+        get('main').then(([el]) => window.Alpine.morph(el, toHtml, {
+            key(el) { return el.id }
+        }))
+
+        get('input').should(haveValue('foo'))
+    },
+)

Some files were not shown because too many files changed in this diff