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

Merge branch 'main' into jlb/fix-combobox-bugs

Jason Beggs 1 жил өмнө
parent
commit
46fd4c62cd
50 өөрчлөгдсөн 752 нэмэгдсэн , 416 устгасан
  1. 2 2
      benchmarks/giant.html
  2. 14 283
      index.html
  3. 43 15
      package-lock.json
  4. 1 0
      package.json
  5. 1 1
      packages/alpinejs/package.json
  6. 2 1
      packages/alpinejs/src/alpine.js
  7. 8 23
      packages/alpinejs/src/clone.js
  8. 2 1
      packages/alpinejs/src/directives.js
  9. 25 1
      packages/alpinejs/src/directives/x-data.js
  10. 1 1
      packages/alpinejs/src/directives/x-model.js
  11. 2 0
      packages/alpinejs/src/directives/x-teleport.js
  12. 1 1
      packages/alpinejs/src/directives/x-transition.js
  13. 18 20
      packages/alpinejs/src/entangle.js
  14. 11 2
      packages/alpinejs/src/evaluator.js
  15. 2 2
      packages/alpinejs/src/mutation.js
  16. 7 7
      packages/alpinejs/src/scope.js
  17. 5 0
      packages/anchor/builds/cdn.js
  18. 3 0
      packages/anchor/builds/module.js
  19. 17 0
      packages/anchor/package.json
  20. 77 0
      packages/anchor/src/index.js
  21. 1 1
      packages/collapse/package.json
  22. 1 1
      packages/docs/package.json
  23. 10 3
      packages/docs/src/en/advanced/csp.md
  24. 3 2
      packages/docs/src/en/essentials/installation.md
  25. 213 0
      packages/docs/src/en/plugins/anchor.md
  26. 1 1
      packages/docs/src/en/plugins/morph.md
  27. 1 1
      packages/focus/package.json
  28. 5 4
      packages/focus/src/index.js
  29. 1 1
      packages/intersect/package.json
  30. 2 2
      packages/intersect/src/index.js
  31. 1 1
      packages/mask/package.json
  32. 1 1
      packages/morph/package.json
  33. 21 9
      packages/morph/src/morph.js
  34. 1 1
      packages/morph/src/old_morph.js
  35. 1 1
      packages/persist/package.json
  36. 15 1
      packages/persist/src/index.js
  37. 1 1
      packages/ui/package.json
  38. 2 2
      packages/ui/src/list-context.js
  39. 2 2
      packages/ui/src/listbox.js
  40. 18 7
      packages/ui/src/menu.js
  41. 1 0
      scripts/build.js
  42. 6 0
      scripts/release.js
  43. 14 0
      tests/cypress/integration/directives/x-on.spec.js
  44. 35 0
      tests/cypress/integration/entangle.spec.js
  45. 13 0
      tests/cypress/integration/plugins/anchor.spec.js
  46. 1 1
      tests/cypress/integration/plugins/mask.spec.js
  47. 79 12
      tests/cypress/integration/plugins/morph.spec.js
  48. 1 1
      tests/cypress/integration/plugins/ui/combobox.spec.js
  49. 58 0
      tests/cypress/integration/scope.spec.js
  50. 1 0
      tests/cypress/spec.html

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

+ 14 - 283
index.html

@@ -5,292 +5,23 @@
     <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="{
-        my_array: [{x:'x'},{x:'y'}],
-        click() {
-            this.my_array = [{x:'a'},{x:'b'}];
-        }
-    }">
-
-        <!-- Loop with plain div -->
-        <template x-for="item in my_array">
-            <div x-text="item.x"></div>
-        </template>
-
-        <!-- Loop with div nested inside component -->
-        <template x-for="item in my_array">
-            <div x-data="some_component" >
-                <div x-text="item.x"></div>
-            </div>
-        </template>
-
-        <button @click="click">Click me</button>
-
+    <hr> <hr> <hr> <hr> <hr>
+    <div x-data>
+        <button id="target">Button</button>
+
+        <article
+            x-anchor.bottom
+            x-anchor:to="document.getElementById('target')"
+            :style="{ left: $anchor.x+'px', top: $anchor.y+'px' }"
+            style="position: absolute; left: 0;"
+        >
+            Tooltip contents
+        </article>
     </div>
-        <script>
-            document.addEventListener('alpine:init', () => {
-                Alpine.data('some_component', () => ({}));
-            });
-
-        </script>
-
-
-
-
-    <button wire:click.prefetch"...">
-        Do something
-    </button>
-
-
-
-
-
-
-
-
-
-
-<div x-data="{ count: $url(1) }">
-    <button @click="count++">+</button>
-    <button @click="count--">-</button>
-
-    <h1 x-text="count"></h1>
-</div>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-    <br>
-    <br>
-    <br>
-    <br>
-    <br>
-    <br>
-    <br>
-    <br>
-    <br>
-    <br>
-    <br>
-    <br>
-    <br>
-    <br>
-    <br>
-    <br>
-    <br>
-    <br>
-    <br>
-    <br>
-    <br>
-
-
-    <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>
-
-    <div x-data="{ foo: undefined }">
-        Yo: <input type="text" x-model="foo">
-    </div>
-
-    <!-- Play around here... -->
-
-                    <div class="relative">
-                        <div>Query: <span x-text="query"></span></div>
-                        <span class="inline-block w-full rounded-md shadow-sm">
-                            <div class="relative w-full cursor-default rounded-md border border-gray-300 bg-white py-2 pl-2 pr-10 text-left transition duration-150 ease-in-out focus-within:border-blue-700 focus-within:outline-none focus-within:ring-1 focus-within:ring-blue-700 sm:text-sm sm:leading-5">
-                                <span class="block flex flex-wrap gap-2">
-                                    <span x-show="activePersons.length === 0" class="p-0.5">Empty</span>
-                                    <template x-for="person in activePersons" :key="person.id">
-                                        <span class="flex items-center gap-1 rounded bg-blue-50 px-2 py-0.5">
-                                            <span x-text="person.name"></span>
-                                            <svg class="h-4 w-4 cursor-pointer" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" @click.stop.prevent="removePerson(person)">
-                                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
-                                            </svg>
-                                        </span>
-                                    </template>
-                                    <input x-combobox:input @change="query = $event.target.value" class="border-none p-0 focus:ring-0" placeholder="Search..." />
-                                </span>
-                                <button x-combobox:button class="absolute inset-y-0 right-0 flex items-center pr-2">
-                                    <svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="none" stroke="currentColor">
-                                        <path d="M7 7l3-3 3 3m0 6l-3 3-3-3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
-                                    </svg>
-                                </button>
-                            </div>
-                        </span>
-
-                        <div class="absolute mt-1 w-full rounded-md bg-white shadow-lg">
-                            <ul x-combobox:options hold class="shadow-xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5">
-                                <template
-                                    x-for="person in people.filter((person) =>
-                                        person.name.toLowerCase().includes(query.toLowerCase())
-                                    )"
-                                    :key="person.id"
-                                >
-                                    <li x-combobox:option :value="person" class="relative cursor-default select-none py-2 pl-3 pr-9 focus:outline-none" :class="$comboboxOption.isActive ? 'bg-indigo-600 text-white' : 'text-gray-900'">
-                                        <span x-text="person.name" class="block truncate" :class="{ 'font-semibold': $comboboxOption.isSelected, 'font-normal': !$comboboxOption.isSelected }">
-                                        </span>
-                                        <span x-show="$comboboxOption.isSelected" class="absolute inset-y-0 right-0 flex items-center pr-4" :class="{ 'text-white': $comboboxOption.isActive, 'text-indigo-600': !$comboboxOption.isActive }">
-                                            <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
-                                                <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
-                                            </svg>
-                                        </span>
-                                    </li>
-                                </template>
-
-                                <!-- <template x-if="queryPerson">
-                                    <li x-combobox:option :value="queryPerson" class="relative cursor-default select-none py-2 pl-3 pr-9 focus:outline-none" :class="$comboboxOption.isActive ? 'bg-indigo-600 text-white' : 'text-gray-900'">
-                                        <span x-text="'Create ' + queryPerson.name" class="block truncate" :class="{ 'font-semibold': $comboboxOption.isSelected, 'font-normal': !$comboboxOption.isSelected }">
-                                        </span>
-                                        <span x-show="$comboboxOption.isSelected" class="absolute inset-y-0 right-0 flex items-center pr-4" :class="{ 'text-white': $comboboxOption.isActive, 'text-indigo-600': !$comboboxOption.isActive }">
-                                            <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
-                                                <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
-                                            </svg>
-                                        </span>
-                                    </li>
-                                </template> -->
-                            </ul>
-                        </div>
-                    </div>
-                </div>
-                <button class="mt-2 inline-flex items-center rounded border border-gray-300 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
-                    Submit
-                </button>
-            </form>
-        </div>
-    </div>
-</div>
-
-<div
-    x-data="{
-        query: '',
-        selected: null,
-        frameworks: [
-            {
-                id: 1,
-                name: 'Laravel',
-                disabled: false,
-            },
-            {
-                id: 2,
-                name: 'Ruby on Rails',
-                disabled: false,
-            },
-            {
-                id: 3,
-                name: 'Django',
-                disabled: false,
-            },
-            {
-                id: 4,
-                name: 'Express',
-                disabled: false,
-            },
-            {
-                id: 5,
-                name: 'Phoenix',
-                disabled: false,
-            },
-            {
-                id: 6,
-                name: 'Adonis',
-                disabled: false,
-            },
-            {
-                id: 7,
-                name: 'NextJS',
-                disabled: false,
-            },
-        ],
-        get filteredFrameworks() {
-            return this.query === ''
-                ? this.frameworks
-                : this.frameworks.filter((framework) => {
-                    return framework.name.toLowerCase().includes(this.query.toLowerCase())
-                })
-        }
-    }"
-
-    class="flex h-full w-screen justify-center bg-gray-50 p-12"
->
-    <div x-combobox x-model="selected">
-        <label x-combobox:label class="block text-sm text-gray-600">
-            Select framework
-        </label>
-
-        <div class="mt-1 relative">
-            <div class="flex items-center justify-between gap-2 w-64 bg-white pl-5 pr-3 py-2.5 rounded-md shadow">
-                <input
-                    x-combobox:input
-                    :display-value="framework => framework.name"
-                    @change="query = $event.target.value"
-                    class="border-none p-0 focus:outline-none focus:ring-0"
-                    placeholder="Search..."
-                />
-                <button x-combobox:button class="absolute inset-y-0 right-0 flex items-center pr-2">
-                    <!-- Heroicons up/down -->
-                    <svg class="shrink-0 w-5 h-5 text-gray-500" viewBox="0 0 20 20" fill="none" stroke="currentColor"><path d="M7 7l3-3 3 3m0 6l-3 3-3-3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>
-                </button>
-            </div>
-
-            <div x-combobox:options x-cloak class="absolute right-0 w-64 max-h-60 mt-2 z-10 origin-top-right overflow-hidden bg-white border border-gray-200 rounded-md shadow-md outline-none" x-transition>
-                <ul class="divide-y divide-gray-100">
-                    <template
-                        x-for="framework in filteredFrameworks"
-                        :key="framework.id"
-                        hidden
-                    >
-                        <li
-                            x-combobox:option
-                            :value="framework"
-                            :disabled="framework.disabled"
-                            :class="{
-                                'bg-cyan-500/10 text-gray-900': $comboboxOption.isActive,
-                                'text-gray-600': ! $comboboxOption.isActive,
-                                'opacity-50 cursor-not-allowed': $comboboxOption.isDisabled,
-                            }"
-                            class="flex items-center cursor-default justify-between gap-2 w-full px-4 py-2 text-sm"
-                        >
-                            <span x-text="framework.name"></span>
-
-                            <span x-show="$comboboxOption.isSelected" class="text-cyan-600 font-bold">&check;</span>
-                        </li>
-                    </template>
-                </ul>
-
-                <p x-show="filteredFrameworks.length == 0" class="px-4 py-2 text-sm text-gray-600">No frameworks match your query.</p>
-            </div>
-        </div>
-        <div>local selected: <span x-text="selected?.name"></span></div>
-        <div>internal selected: <span x-text="$combobox.value?.name"></span></div>
-            <article x-text="$combobox.activeIndex"></article>
-    </div>
-</div>
-
-
-
-
+    <hr> <hr> <hr> <hr> <hr>
 </html>

+ 43 - 15
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,15 +7854,19 @@
             }
         },
         "packages/alpinejs": {
-            "version": "3.12.3",
+            "version": "3.13.2",
             "license": "MIT",
             "dependencies": {
                 "@vue/reactivity": "~3.1.1"
             }
         },
+        "packages/anchor": {
+            "version": "3.13.2",
+            "license": "MIT"
+        },
         "packages/collapse": {
             "name": "@alpinejs/collapse",
-            "version": "3.12.3",
+            "version": "3.13.2",
             "license": "MIT"
         },
         "packages/csp": {
@@ -7846,12 +7879,12 @@
         },
         "packages/docs": {
             "name": "@alpinejs/docs",
-            "version": "3.12.3-revision.1",
+            "version": "3.13.2-revision.1",
             "license": "MIT"
         },
         "packages/focus": {
             "name": "@alpinejs/focus",
-            "version": "3.12.1",
+            "version": "3.13.2",
             "license": "MIT",
             "dependencies": {
                 "focus-trap": "^6.9.4",
@@ -7868,17 +7901,17 @@
         },
         "packages/intersect": {
             "name": "@alpinejs/intersect",
-            "version": "3.12.3",
+            "version": "3.13.2",
             "license": "MIT"
         },
         "packages/mask": {
             "name": "@alpinejs/mask",
-            "version": "3.12.3",
+            "version": "3.13.2",
             "license": "MIT"
         },
         "packages/morph": {
             "name": "@alpinejs/morph",
-            "version": "3.12.3",
+            "version": "3.13.2",
             "license": "MIT"
         },
         "packages/navigate": {
@@ -7891,19 +7924,14 @@
         },
         "packages/persist": {
             "name": "@alpinejs/persist",
-            "version": "3.12.3",
+            "version": "3.13.2",
             "license": "MIT"
         },
         "packages/ui": {
             "name": "@alpinejs/ui",
-            "version": "3.12.3-beta.0",
+            "version": "3.13.1-beta.0",
             "license": "MIT",
-            "devDependencies": {
-                "alpinejs": "file:../alpinejs"
-            },
-            "peerDependencies": {
-                "alpinejs": "^3.10.0"
-            }
+            "devDependencies": {}
         }
     }
 }

+ 1 - 0
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"

+ 1 - 1
packages/alpinejs/package.json

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

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

@@ -5,7 +5,7 @@ import { onElRemoved, onAttributeRemoved, onAttributesAdded, mutateDom, deferMut
 import { mergeProxies, closestDataStack, addScopeToNode, scope as $data } from './scope'
 import { setEvaluator, evaluate, evaluateLater, dontAutoEvaluateFunctions } from './evaluator'
 import { transition } from './directives/x-transition'
-import { clone, cloneNode, skipDuringClone, onlyDuringClone } from './clone'
+import { clone, cloneNode, skipDuringClone, onlyDuringClone, interceptClone } from './clone'
 import { interceptor } from './interceptor'
 import { getBinding as bound, extractProp } from './utils/bind'
 import { debounce } from './utils/debounce'
@@ -39,6 +39,7 @@ let Alpine = {
     onlyDuringClone,
     addRootSelector,
     addInitSelector,
+    interceptClone,
     addScopeToNode,
     deferMutations,
     mapAttributes,

+ 8 - 23
packages/alpinejs/src/clone.js

@@ -12,18 +12,15 @@ export function onlyDuringClone(callback) {
     return (...args) => isCloning && callback(...args)
 }
 
+let interceptors = []
+
+export function interceptClone(callback) {
+    interceptors.push(callback)
+}
+
 export function cloneNode(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)
-    }
+    interceptors.forEach(i => i(from, to))
 
     isCloning = true
 
@@ -41,7 +38,7 @@ export function cloneNode(from, to)
     isCloning = false
 }
 
-let isCloningLegacy = false
+export let isCloningLegacy = false
 
 /** deprecated */
 export function clone(oldEl, newEl) {
@@ -90,15 +87,3 @@ function dontRegisterReactiveSideEffects(callback) {
 
     overrideEffect(cache)
 }
-
-// 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.
-export function shouldSkipRegisteringDataDuringClone(el) {
-    if (! isCloning) return false
-    if (isCloningLegacy) return true
-
-    return el.hasAttribute('data-has-alpine-state')
-}
-

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

@@ -182,7 +182,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
 
@@ -203,6 +203,7 @@ let directiveOrder = [
     'ref',
     'data',
     'id',
+    'anchor',
     'bind',
     'init',
     'for',

+ 25 - 1
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 { shouldSkipRegisteringDataDuringClone } from '../clone'
+import { interceptClone, isCloning, isCloningLegacy } from '../clone'
 import { addScopeToNode } from '../scope'
 import { injectMagics, magic } from '../magics'
 import { reactive } from '../reactivity'
@@ -41,3 +41,27 @@ directive('data', ((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')
+}

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

@@ -95,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()

+ 2 - 0
packages/alpinejs/src/directives/x-teleport.js

@@ -61,6 +61,8 @@ directive('teleport', (el, { modifiers, expression }, { cleanup }) => {
             placeInDom(el._x_teleport, target, modifiers)
         })
     }
+
+    cleanup(() => clone.remove())
 })
 
 let teleportContainerDuringClone = document.createElement('div')

+ 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(JSON.parse(JSON.stringify(outer))) // We need to break internal references using parse/stringify...
-            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(JSON.parse(innerHashLatest ?? null)) // We need to break internal references using parse/stringify...
-                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
+}

+ 11 - 2
packages/alpinejs/src/evaluator.js

@@ -66,7 +66,7 @@ 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()"
@@ -78,7 +78,16 @@ function generateFunctionFromString(expression, el) {
 
     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()

+ 2 - 2
packages/alpinejs/src/mutation.js

@@ -155,11 +155,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()
             }

+ 7 - 7
packages/alpinejs/src/scope.js

@@ -64,14 +64,14 @@ let mergeProxyTrap = {
         )
     },
 
-    set({ objects }, name, value) {
-        return Reflect.set(
-            objects.find((obj) =>
+    set({ objects }, name, value, thisProxy) {
+        const target = objects.find((obj) =>
                 Object.prototype.hasOwnProperty.call(obj, name)
-            ) || objects[objects.length-1],
-            name,
-            value
-        )
+            ) || 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);
     },
 }
 

+ 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.2",
+    "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.13.0",
+    "version": "3.13.2",
     "description": "Collapse and expand elements with robust animations",
     "homepage": "https://alpinejs.dev/plugins/collapse",
     "repository": {

+ 1 - 1
packages/docs/package.json

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

+ 10 - 3
packages/docs/src/en/advanced/csp.md

@@ -14,14 +14,21 @@ In order to accommodate environments where this CSP is necessary, Alpine will of
 <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:
+The CSP build hasn’t been officially released yet. In the meantime, you may build it from source. To do this, clone the [`alpinejs/alpine`](https://github.com/alpinejs/alpine) repository and run:
+
+```shell
+npm install
+npm run build
+```
+
+This will generate a `/packages/csp/dist/` directory with the built files. After copying the appropriate file into your project, you can include it either via `<script>` tag or module import:
 
 <a name="script-tag"></a>
 ### Script tag
 
 ```alpine
 <html>
-    <script src="alpinejs/alpinejs-csp/cdn.js" defer></script>
+    <script src="/path/to/cdn.js" defer></script>
 </html>
 ```
 
@@ -29,7 +36,7 @@ The CSP build hasn’t been officially released yet. In the meantime, you may [b
 ### Module import
 
 ```js
-import Alpine from '@alpinejs/csp'
+import Alpine from './path/to/module.esm.js'
 
 window.Alpine = Alpine
 window.Alpine.start()

+ 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.13.0/dist/cdn.min.js"></script>
+<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.2/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)

+ 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 Collapse 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

+ 1 - 1
packages/focus/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/focus",
-    "version": "3.13.0",
+    "version": "3.13.2",
     "description": "Manage focus within a page",
     "homepage": "https://alpinejs.dev/plugins/focus",
     "repository": {

+ 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.

+ 1 - 1
packages/intersect/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/intersect",
-    "version": "3.13.0",
+    "version": "3.13.2",
     "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.13.0",
+    "version": "3.13.2",
     "description": "An Alpine plugin for input masking",
     "homepage": "https://alpinejs.dev/plugins/mask",
     "repository": {

+ 1 - 1
packages/morph/package.json

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

+ 21 - 9
packages/morph/src/morph.js

@@ -120,6 +120,11 @@ export function morph(from, toHtml, options) {
     }
 
     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 = {}
 
@@ -127,6 +132,10 @@ export function morph(from, toHtml, options) {
         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)
 
@@ -156,8 +165,8 @@ export function morph(from, toHtml, options) {
             }
 
             // Handle conditional markers (presumably added by backends like Livewire)...
-            let isIf = node => node && node.nodeType === 8 && node.textContent === ' __BLOCK__ '
-            let isEnd = node => 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 nestedIfCount = 0
@@ -283,7 +292,7 @@ export function morph(from, toHtml, options) {
             currentFrom = currentFromNext
         }
 
-        // Cleanup extra froms.
+        // Cleanup extra forms.
         let removals = []
 
         // We need to collect the "removals" first before actually
@@ -444,12 +453,6 @@ function getFirstNode(parent) {
 }
 
 function getNextSibling(parent, reference) {
-    if (reference._x_teleport) {
-        return reference._x_teleport
-    } else if (reference.teleportBack) {
-        return reference.teleportBack
-    }
-
     let next
 
     if (parent instanceof Block) {
@@ -487,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

+ 1 - 1
packages/persist/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/persist",
-    "version": "3.13.0",
+    "version": "3.13.2",
     "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}`

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
     "name": "@alpinejs/ui",
-    "version": "3.13.0-beta.0",
+    "version": "3.13.2-beta.0",
     "description": "Headless UI components for Alpine",
     "homepage": "https://alpinejs.dev/components#headless",
     "repository": {

+ 2 - 2
packages/ui/src/list-context.js

@@ -175,7 +175,7 @@ export function generateContext(Alpine, multiple, orientation, activateSelectedO
          * 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
-         * activeted after filtering the list)
+         * activated after filtering the list)
          */
         wasActivatedByKeyPress() {return this.activatedByKeyPress},
 
@@ -204,7 +204,7 @@ export function generateContext(Alpine, multiple, orientation, activateSelectedO
         },
 
         /**
-         * Handle active key traveral...
+         * Handle active key traversal...
          */
         nextKey() {
             if (! this.activeKey) return

+ 2 - 2
packages/ui/src/listbox.js

@@ -13,16 +13,16 @@ export default function (Alpine) {
         let data = Alpine.$data(el)
 
         return {
-            // @todo: remove "selected" and "active" when 1.0 is tagged...
+            // @deprecated:
             get selected() {
                 return data.__value
             },
+            // @deprecated:
             get active() {
                 let active = data.__context.getActiveItem()
 
                 return active && active.value
             },
-
             get value() {
                 return data.__value
             },

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

+ 1 - 0
scripts/build.js

@@ -11,6 +11,7 @@ let zlib = require('zlib');
     'intersect',
     'persist',
     'collapse',
+    'anchor',
     'morph',
     'focus',
     'mask',

+ 6 - 0
scripts/release.js

@@ -51,6 +51,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)
 
@@ -89,6 +92,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')
 

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

+ 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 - 1
tests/cypress/integration/plugins/mask.spec.js

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

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

@@ -337,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">
@@ -388,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>
         `
@@ -419,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>
         `
@@ -470,3 +493,47 @@ test('can morph @event handlers', [
         get('button').should(haveText('buzz'));
     }
 );
+
+test.only('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'))
+    },
+)

+ 1 - 1
tests/cypress/integration/plugins/ui/combobox.spec.js

@@ -271,7 +271,7 @@ test('"name" prop',
     },
 );
 
-test('Preserves currenty active keyboard selection while options change from searching even if there\'s a selected option in the filtered results',
+test('Preserves currently active keyboard selection while options change from searching even if there\'s a selected option in the filtered results',
     [html`
         <div
             x-data="{

+ 58 - 0
tests/cypress/integration/scope.spec.js

@@ -0,0 +1,58 @@
+import { haveText, html, test } from "../utils";
+
+test(
+    "properly merges the datastack",
+    [
+        html`
+            <div x-data="{ foo: 'fizz' }">
+                <div x-data="{ bar: 'buzz' }">
+                    <span x-text="foo + bar"></span>
+                </div>
+            </div>
+        `,
+    ],
+    ({ get }) => {
+        get("span").should(haveText("fizzbuzz"));
+    }
+);
+
+test(
+    "merges stack from bottom up",
+    [
+        html`
+            <div x-data="{ foo: 'fizz' }">
+                <div x-data="{ foo: 'buzz', get bar() { return this.foo } }">
+                    <span id="one" x-text="bar + foo"></span>
+                </div>
+                <span id="two" x-text="foo"></span>
+            </div>
+        `,
+    ],
+    ({ get }) => {
+        get("span#one").should(haveText("buzzbuzz"));
+        get("span#two").should(haveText("fizz"));
+    }
+);
+
+test(
+    "handles getter setter pairs",
+    [
+        html`
+            <div x-data="{ foo: 'fizzbuzz' }">
+                <div
+                    x-data="{ get bar() { return this.foo }, set bar(value) { this.foo = value } }"
+                >
+                    <span id="one" x-text="bar" @click="bar = 'foobar'"></span>
+                </div>
+                <span id="two" x-text="foo"></span>
+            </div>
+        `,
+    ],
+    ({ get }) => {
+        get("span#one").should(haveText("fizzbuzz"));
+        get("span#two").should(haveText("fizzbuzz"));
+        get("span#one").click();
+        get("span#one").should(haveText("foobar"));
+        get("span#two").should(haveText("foobar"));
+    }
+);

+ 1 - 0
tests/cypress/spec.html

@@ -11,6 +11,7 @@
     <script src="/../../packages/focus/dist/cdn.js"></script>
     <script src="/../../packages/intersect/dist/cdn.js"></script>
     <script src="/../../packages/collapse/dist/cdn.js"></script>
+    <script src="/../../packages/anchor/dist/cdn.js"></script>
     <script src="/../../packages/mask/dist/cdn.js"></script>
     <script src="/../../packages/ui/dist/cdn.js"></script>
     <script>