瀏覽代碼

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

Jason Beggs 1 年之前
父節點
當前提交
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 ">
                         <div class="compare-show-header Subhead hx_Subhead--responsive ">
                             <h1 class="Subhead-heading ">Comparing changes</h1>
                             <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.
                                 new pull request.
                                 If you need to, you can also <button type="button"
                                 If you need to, you can also <button type="button"
                                     class="btn-link js-toggle-range-editor-cross-repo">compare across forks</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>
                 </path>
             </svg>
             </svg>
         </button>
         </button>
-        You cant perform that action at this time.
+        You can't perform that action at this time.
     </div>
     </div>
 
 
     <div class="js-stale-session-flash flash flash-warn flash-banner" hidden>
     <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/focus/dist/cdn.js"></script>
     <script src="./packages/mask/dist/cdn.js"></script>
     <script src="./packages/mask/dist/cdn.js"></script>
     <script src="./packages/ui/dist/cdn.js" defer></script> -->
     <script src="./packages/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="./packages/alpinejs/dist/cdn.js" defer></script>
     <!-- <script src="//cdn.tailwindcss.com"></script> -->
     <!-- <script src="//cdn.tailwindcss.com"></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>
     </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>
 </html>

+ 43 - 15
package-lock.json

@@ -7,6 +7,9 @@
             "workspaces": [
             "workspaces": [
                 "packages/*"
                 "packages/*"
             ],
             ],
+            "dependencies": {
+                "@floating-ui/dom": "^1.5.3"
+            },
             "devDependencies": {
             "devDependencies": {
                 "axios": "^0.21.1",
                 "axios": "^0.21.1",
                 "chalk": "^4.1.1",
                 "chalk": "^4.1.1",
@@ -17,6 +20,10 @@
                 "jest": "^26.6.3"
                 "jest": "^26.6.3"
             }
             }
         },
         },
+        "node_modules/@alpinejs/anchor": {
+            "resolved": "packages/anchor",
+            "link": true
+        },
         "node_modules/@alpinejs/collapse": {
         "node_modules/@alpinejs/collapse": {
             "resolved": "packages/collapse",
             "resolved": "packages/collapse",
             "link": true
             "link": true
@@ -1014,6 +1021,28 @@
                 "node": ">=12"
                 "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": {
         "node_modules/@istanbuljs/load-nyc-config": {
             "version": "1.1.0",
             "version": "1.1.0",
             "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
             "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -7825,15 +7854,19 @@
             }
             }
         },
         },
         "packages/alpinejs": {
         "packages/alpinejs": {
-            "version": "3.12.3",
+            "version": "3.13.2",
             "license": "MIT",
             "license": "MIT",
             "dependencies": {
             "dependencies": {
                 "@vue/reactivity": "~3.1.1"
                 "@vue/reactivity": "~3.1.1"
             }
             }
         },
         },
+        "packages/anchor": {
+            "version": "3.13.2",
+            "license": "MIT"
+        },
         "packages/collapse": {
         "packages/collapse": {
             "name": "@alpinejs/collapse",
             "name": "@alpinejs/collapse",
-            "version": "3.12.3",
+            "version": "3.13.2",
             "license": "MIT"
             "license": "MIT"
         },
         },
         "packages/csp": {
         "packages/csp": {
@@ -7846,12 +7879,12 @@
         },
         },
         "packages/docs": {
         "packages/docs": {
             "name": "@alpinejs/docs",
             "name": "@alpinejs/docs",
-            "version": "3.12.3-revision.1",
+            "version": "3.13.2-revision.1",
             "license": "MIT"
             "license": "MIT"
         },
         },
         "packages/focus": {
         "packages/focus": {
             "name": "@alpinejs/focus",
             "name": "@alpinejs/focus",
-            "version": "3.12.1",
+            "version": "3.13.2",
             "license": "MIT",
             "license": "MIT",
             "dependencies": {
             "dependencies": {
                 "focus-trap": "^6.9.4",
                 "focus-trap": "^6.9.4",
@@ -7868,17 +7901,17 @@
         },
         },
         "packages/intersect": {
         "packages/intersect": {
             "name": "@alpinejs/intersect",
             "name": "@alpinejs/intersect",
-            "version": "3.12.3",
+            "version": "3.13.2",
             "license": "MIT"
             "license": "MIT"
         },
         },
         "packages/mask": {
         "packages/mask": {
             "name": "@alpinejs/mask",
             "name": "@alpinejs/mask",
-            "version": "3.12.3",
+            "version": "3.13.2",
             "license": "MIT"
             "license": "MIT"
         },
         },
         "packages/morph": {
         "packages/morph": {
             "name": "@alpinejs/morph",
             "name": "@alpinejs/morph",
-            "version": "3.12.3",
+            "version": "3.13.2",
             "license": "MIT"
             "license": "MIT"
         },
         },
         "packages/navigate": {
         "packages/navigate": {
@@ -7891,19 +7924,14 @@
         },
         },
         "packages/persist": {
         "packages/persist": {
             "name": "@alpinejs/persist",
             "name": "@alpinejs/persist",
-            "version": "3.12.3",
+            "version": "3.13.2",
             "license": "MIT"
             "license": "MIT"
         },
         },
         "packages/ui": {
         "packages/ui": {
             "name": "@alpinejs/ui",
             "name": "@alpinejs/ui",
-            "version": "3.12.3-beta.0",
+            "version": "3.13.1-beta.0",
             "license": "MIT",
             "license": "MIT",
-            "devDependencies": {
-                "alpinejs": "file:../alpinejs"
-            },
-            "peerDependencies": {
-                "alpinejs": "^3.10.0"
-            }
+            "devDependencies": {}
         }
         }
     }
     }
 }
 }

+ 1 - 0
package.json

@@ -8,6 +8,7 @@
         "chalk": "^4.1.1",
         "chalk": "^4.1.1",
         "cypress": "^7.0.0",
         "cypress": "^7.0.0",
         "cypress-plugin-tab": "^1.0.5",
         "cypress-plugin-tab": "^1.0.5",
+        "@floating-ui/dom": "^1.5.3",
         "dot-json": "^1.2.2",
         "dot-json": "^1.2.2",
         "esbuild": "~0.16.17",
         "esbuild": "~0.16.17",
         "jest": "^26.6.3"
         "jest": "^26.6.3"

+ 1 - 1
packages/alpinejs/package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "alpinejs",
     "name": "alpinejs",
-    "version": "3.13.0",
+    "version": "3.13.2",
     "description": "The rugged, minimal JavaScript framework",
     "description": "The rugged, minimal JavaScript framework",
     "homepage": "https://alpinejs.dev",
     "homepage": "https://alpinejs.dev",
     "repository": {
     "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 { mergeProxies, closestDataStack, addScopeToNode, scope as $data } from './scope'
 import { setEvaluator, evaluate, evaluateLater, dontAutoEvaluateFunctions } from './evaluator'
 import { setEvaluator, evaluate, evaluateLater, dontAutoEvaluateFunctions } from './evaluator'
 import { transition } from './directives/x-transition'
 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 { interceptor } from './interceptor'
 import { getBinding as bound, extractProp } from './utils/bind'
 import { getBinding as bound, extractProp } from './utils/bind'
 import { debounce } from './utils/debounce'
 import { debounce } from './utils/debounce'
@@ -39,6 +39,7 @@ let Alpine = {
     onlyDuringClone,
     onlyDuringClone,
     addRootSelector,
     addRootSelector,
     addInitSelector,
     addInitSelector,
+    interceptClone,
     addScopeToNode,
     addScopeToNode,
     deferMutations,
     deferMutations,
     mapAttributes,
     mapAttributes,

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

@@ -12,18 +12,15 @@ export function onlyDuringClone(callback) {
     return (...args) => isCloning && callback(...args)
     return (...args) => isCloning && callback(...args)
 }
 }
 
 
+let interceptors = []
+
+export function interceptClone(callback) {
+    interceptors.push(callback)
+}
+
 export function cloneNode(from, to)
 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
     isCloning = true
 
 
@@ -41,7 +38,7 @@ export function cloneNode(from, to)
     isCloning = false
     isCloning = false
 }
 }
 
 
-let isCloningLegacy = false
+export let isCloningLegacy = false
 
 
 /** deprecated */
 /** deprecated */
 export function clone(oldEl, newEl) {
 export function clone(oldEl, newEl) {
@@ -90,15 +87,3 @@ function dontRegisterReactiveSideEffects(callback) {
 
 
     overrideEffect(cache)
     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) {
 function toParsedDirectives(transformedAttributeMap, originalAttributeOverride) {
     return ({ name, value }) => {
     return ({ name, value }) => {
         let typeMatch = name.match(alpineAttributeRegex())
         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 modifiers = name.match(/\.[^.\]]+(?=[^\]]*$)/g) || []
         let original = originalAttributeOverride || transformedAttributeMap[name] || name
         let original = originalAttributeOverride || transformedAttributeMap[name] || name
 
 
@@ -203,6 +203,7 @@ let directiveOrder = [
     'ref',
     'ref',
     'data',
     'data',
     'id',
     'id',
+    'anchor',
     'bind',
     'bind',
     'init',
     'init',
     'for',
     'for',

+ 25 - 1
packages/alpinejs/src/directives/x-data.js

@@ -2,7 +2,7 @@ import { directive, prefix } from '../directives'
 import { initInterceptors } from '../interceptor'
 import { initInterceptors } from '../interceptor'
 import { injectDataProviders } from '../datas'
 import { injectDataProviders } from '../datas'
 import { addRootSelector } from '../lifecycle'
 import { addRootSelector } from '../lifecycle'
-import { shouldSkipRegisteringDataDuringClone } from '../clone'
+import { interceptClone, isCloning, isCloningLegacy } from '../clone'
 import { addScopeToNode } from '../scope'
 import { addScopeToNode } from '../scope'
 import { injectMagics, magic } from '../magics'
 import { injectMagics, magic } from '../magics'
 import { reactive } from '../reactivity'
 import { reactive } from '../reactivity'
@@ -41,3 +41,27 @@ directive('data', ((el, { expression }, { cleanup }) => {
         undo()
         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())
         cleanup(() => removeResetListener())
     }
     }
 
 
-    // Allow programmatic overiding of x-model.
+    // Allow programmatic overriding of x-model.
     el._x_model = {
     el._x_model = {
         get() {
         get() {
             return getValue()
             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)
             placeInDom(el._x_teleport, target, modifiers)
         })
         })
     }
     }
+
+    cleanup(() => clone.remove())
 })
 })
 
 
 let teleportContainerDuringClone = document.createElement('div')
 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) => {
         ? new Promise((resolve, reject) => {
             el._x_transition.out(() => {}, () => resolve(hide))
             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)
         : 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 }) {
 export function entangle({ get: outerGet, set: outerSet }, { get: innerGet, set: innerSet }) {
     let firstRun = true
     let firstRun = true
-    let outerHash, innerHash, outerHashLatest, innerHashLatest
+    let outerHash
 
 
     let reference = effect(() => {
     let reference = effect(() => {
-        let outer, inner
-
+        const outer = outerGet()
+        const inner = innerGet()
         if (firstRun) {
         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
             firstRun = false
+            outerHash = JSON.stringify(outer)
         } else {
         } else {
-            outer = outerGet()
-            inner = innerGet()
-
-            outerHashLatest = JSON.stringify(outer)
-            innerHashLatest = JSON.stringify(inner)
+            const outerHashLatest = JSON.stringify(outer)
 
 
             if (outerHashLatest !== outerHash) { // If outer changed...
             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...
             } 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 () => {
     return () => {
         release(reference)
         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
     let AsyncFunction = Object.getPrototypeOf(async function(){}).constructor
 
 
     // Some expressions that are useful in Alpine are not valid as the right side of an expression.
     // 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.
     // calling function so that we don't throw an error AND a "return" statement can b e used.
     let rightSideSafeExpression = 0
     let rightSideSafeExpression = 0
         // Support expressions starting with "if" statements like: "if (...) doSomething()"
         // Support expressions starting with "if" statements like: "if (...) doSomething()"
@@ -78,7 +78,16 @@ function generateFunctionFromString(expression, el) {
 
 
     const safeAsyncFunction = () => {
     const safeAsyncFunction = () => {
         try {
         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 ) {
         } catch ( error ) {
             handleError( error, el, expression )
             handleError( error, el, expression )
             return Promise.resolve()
             return Promise.resolve()

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

@@ -155,11 +155,11 @@ function onMutate(mutations) {
             // New attribute.
             // New attribute.
             if (el.hasAttribute(name) && oldValue === null) {
             if (el.hasAttribute(name) && oldValue === null) {
                 add()
                 add()
-            // Changed atttribute.
+            // Changed attribute.
             } else if (el.hasAttribute(name)) {
             } else if (el.hasAttribute(name)) {
                 remove()
                 remove()
                 add()
                 add()
-            // Removed atttribute.
+            // Removed attribute.
             } else {
             } else {
                 remove()
                 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)
                 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",
     "name": "@alpinejs/collapse",
-    "version": "3.13.0",
+    "version": "3.13.2",
     "description": "Collapse and expand elements with robust animations",
     "description": "Collapse and expand elements with robust animations",
     "homepage": "https://alpinejs.dev/plugins/collapse",
     "homepage": "https://alpinejs.dev/plugins/collapse",
     "repository": {
     "repository": {

+ 1 - 1
packages/docs/package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "@alpinejs/docs",
     "name": "@alpinejs/docs",
-    "version": "3.13.0-revision.1",
+    "version": "3.13.2-revision.3",
     "description": "The documentation for Alpine",
     "description": "The documentation for Alpine",
     "author": "Caleb Porzio",
     "author": "Caleb Porzio",
     "license": "MIT"
     "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>
 <a name="installation"></a>
 ## Installation
 ## 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>
 <a name="script-tag"></a>
 ### Script tag
 ### Script tag
 
 
 ```alpine
 ```alpine
 <html>
 <html>
-    <script src="alpinejs/alpinejs-csp/cdn.js" defer></script>
+    <script src="/path/to/cdn.js" defer></script>
 </html>
 </html>
 ```
 ```
 
 
@@ -29,7 +36,7 @@ The CSP build hasn’t been officially released yet. In the meantime, you may [b
 ### Module import
 ### Module import
 
 
 ```js
 ```js
-import Alpine from '@alpinejs/csp'
+import Alpine from './path/to/module.esm.js'
 
 
 window.Alpine = Alpine
 window.Alpine = Alpine
 window.Alpine.start()
 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.
 Notice the `@3.x.x` in the provided CDN link. This will pull the latest version of Alpine version 3. For stability in production, it's recommended that you hardcode the latest version in the CDN link.
 
 
 ```alpine
 ```alpine
-<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.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.
 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.
 > 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()`.
 > 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)
 [→ 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
 title: Morph
 description: Morph an element into the provided HTML
 description: Morph an element into the provided HTML
 graph_image: https://alpinejs.dev/social_morph.jpg
 graph_image: https://alpinejs.dev/social_morph.jpg

+ 1 - 1
packages/focus/package.json

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

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

@@ -134,12 +134,13 @@ export default function (Alpine) {
 
 
                 // Start trapping.
                 // Start trapping.
                 if (value && ! oldValue) {
                 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()
                         trap.activate()
-                    });
+                    }, 15)
                 }
                 }
 
 
                 // Stop trapping.
                 // Stop trapping.

+ 1 - 1
packages/intersect/package.json

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

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

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

+ 1 - 1
packages/mask/package.json

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

+ 1 - 1
packages/morph/package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "@alpinejs/morph",
     "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",
     "description": "Diff and patch a block of HTML on a page with an HTML template",
     "homepage": "https://alpinejs.dev/plugins/morph",
     "homepage": "https://alpinejs.dev/plugins/morph",
     "repository": {
     "repository": {

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

@@ -120,6 +120,11 @@ export function morph(from, toHtml, options) {
     }
     }
 
 
     function patchChildren(from, to) {
     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 fromKeys = keyToMap(from.children)
         let fromKeyHoldovers = {}
         let fromKeyHoldovers = {}
 
 
@@ -127,6 +132,10 @@ export function morph(from, toHtml, options) {
         let currentFrom = getFirstNode(from)
         let currentFrom = getFirstNode(from)
 
 
         while (currentTo) {
         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 toKey = getKey(currentTo)
             let fromKey = getKey(currentFrom)
             let fromKey = getKey(currentFrom)
 
 
@@ -156,8 +165,8 @@ export function morph(from, toHtml, options) {
             }
             }
 
 
             // Handle conditional markers (presumably added by backends like Livewire)...
             // 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)) {
             if (isIf(currentTo) && isIf(currentFrom)) {
                 let nestedIfCount = 0
                 let nestedIfCount = 0
@@ -283,7 +292,7 @@ export function morph(from, toHtml, options) {
             currentFrom = currentFromNext
             currentFrom = currentFromNext
         }
         }
 
 
-        // Cleanup extra froms.
+        // Cleanup extra forms.
         let removals = []
         let removals = []
 
 
         // We need to collect the "removals" first before actually
         // We need to collect the "removals" first before actually
@@ -444,12 +453,6 @@ function getFirstNode(parent) {
 }
 }
 
 
 function getNextSibling(parent, reference) {
 function getNextSibling(parent, reference) {
-    if (reference._x_teleport) {
-        return reference._x_teleport
-    } else if (reference.teleportBack) {
-        return reference.teleportBack
-    }
-
     let next
     let next
 
 
     if (parent instanceof Block) {
     if (parent instanceof Block) {
@@ -487,3 +490,12 @@ function monkeyPatchDomSetAttributeToAllowAtSymbols() {
         this.setAttributeNode(attr)
         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
             currentFrom = currentFromNext
         }
         }
 
 
-        // Cleanup extra froms.
+        // Cleanup extra forms.
         let removals = []
         let removals = []
 
 
         // We need to collect the "removals" first before actually
         // We need to collect the "removals" first before actually

+ 1 - 1
packages/persist/package.json

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

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

@@ -1,7 +1,21 @@
 export default function (Alpine) {
 export default function (Alpine) {
     let persist = () => {
     let persist = () => {
         let alias
         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) => {
         return Alpine.interceptor((initialValue, getter, setter, path, key) => {
             let lookup = alias || `_x_${path}`
             let lookup = alias || `_x_${path}`

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
 {
     "name": "@alpinejs/ui",
     "name": "@alpinejs/ui",
-    "version": "3.13.0-beta.0",
+    "version": "3.13.2-beta.0",
     "description": "Headless UI components for Alpine",
     "description": "Headless UI components for Alpine",
     "homepage": "https://alpinejs.dev/components#headless",
     "homepage": "https://alpinejs.dev/components#headless",
     "repository": {
     "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
          * Return true if the latest active element was activated
          * by the user (i.e. using the arrow keys) and false if was
          * by the user (i.e. using the arrow keys) and false if was
          * activated automatically by alpine (i.e. first element automatically
          * activated automatically by alpine (i.e. first element automatically
-         * activeted after filtering the list)
+         * activated after filtering the list)
          */
          */
         wasActivatedByKeyPress() {return this.activatedByKeyPress},
         wasActivatedByKeyPress() {return this.activatedByKeyPress},
 
 
@@ -204,7 +204,7 @@ export function generateContext(Alpine, multiple, orientation, activateSelectedO
         },
         },
 
 
         /**
         /**
-         * Handle active key traveral...
+         * Handle active key traversal...
          */
          */
         nextKey() {
         nextKey() {
             if (! this.activeKey) return
             if (! this.activeKey) return

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

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

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

@@ -14,7 +14,7 @@ export default function (Alpine) {
                 return $data.__activeEl == $data.__itemEl
                 return $data.__activeEl == $data.__itemEl
             },
             },
             get isDisabled() {
             get isDisabled() {
-                return el.__isDisabled.value
+                return $data.__itemEl.__isDisabled.value
             },
             },
         }
         }
     })
     })
@@ -29,14 +29,19 @@ function handleRoot(el, Alpine) {
                 __itemEls: [],
                 __itemEls: [],
                 __activeEl: null,
                 __activeEl: null,
                 __isOpen: false,
                 __isOpen: false,
-                __open() {
+                __open(activationStrategy) {
                     this.__isOpen = true
                     this.__isOpen = true
 
 
                     // Safari needs more of a "tick" for focusing after x-show for some reason.
                     // 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
                     // Probably because Alpine adds an extra tick when x-showing for @click.outside
                     let nextTick = callback => requestAnimationFrame(() => requestAnimationFrame(callback))
                     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) {
                 __close(focusAfter = true) {
                     this.__isOpen = false
                     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' },
         'x-init'() { if (this.$el.tagName.toLowerCase() === 'button' && ! this.$el.hasAttribute('type')) this.$el.type = 'button' },
         '@click'() { this.$data.__open() },
         '@click'() { this.$data.__open() },
         '@keydown.down.stop.prevent'() { 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.space.stop.prevent'() { this.$data.__open() },
         '@keydown.enter.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) {
 function handleItems(el, Alpine) {
     Alpine.bind(el, {
     Alpine.bind(el, {
         'x-ref': '__items',
         'x-ref': '__items',
@@ -153,10 +164,10 @@ function handleItem(el, Alpine) {
             },
             },
             'x-id'() { return ['alpine-menu-item'] },
             'x-id'() { return ['alpine-menu-item'] },
             ':id'() { return this.$id('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',
             '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',
     'intersect',
     'persist',
     'persist',
     'collapse',
     'collapse',
+    'anchor',
     'morph',
     'morph',
     'focus',
     'focus',
     'mask',
     'mask',

+ 6 - 0
scripts/release.js

@@ -51,6 +51,9 @@ function writeNewAlpineVersion() {
     writeToPackageDotJson('collapse', 'version', version)
     writeToPackageDotJson('collapse', 'version', version)
     console.log('Bumping @alpinejs/collapse package.json: '+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)
     writeToPackageDotJson('morph', 'version', version)
     console.log('Bumping @alpinejs/morph package.json: '+version)
     console.log('Bumping @alpinejs/morph package.json: '+version)
 
 
@@ -89,6 +92,9 @@ function publish() {
     console.log('Publishing @alpinejs/collapse on NPM...');
     console.log('Publishing @alpinejs/collapse on NPM...');
     runFromPackage('collapse', 'npm publish --access public')
     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...');
     console.log('Publishing @alpinejs/morph on NPM...');
     runFromPackage('morph', 'npm publish --access public')
     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'))
         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',
 test('.dot modifier correctly binds event listener with namespace',
     html`
     html`

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

@@ -69,3 +69,38 @@ test.skip('can release entanglement',
         get('input[outer]').should(haveValue('foobar'))
         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">`],
     [html`<input x-data x-mask:dynamic="$money">`],
     ({ get }) => {
     ({ get }) => {
         get('input').type('40.00').should(haveValue('40.00'))
         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',
 test('can morph different inline nodes',
     [html`
     [html`
     <div id="from">
     <div id="from">
@@ -388,19 +411,19 @@ test('can morph table tr',
 test('can morph with conditional markers',
 test('can morph with conditional markers',
     [html`
     [html`
         <main>
         <main>
-            <!-- __BLOCK__ -->
+            <!--[if BLOCK]><![endif]-->
             <div>foo<input></div>
             <div>foo<input></div>
-            <!-- __ENDBLOCK__ -->
+            <!--[if ENDBLOCK]><![endif]-->
             <div>bar<input></div>
             <div>bar<input></div>
         </main>
         </main>
     `],
     `],
     ({ get }, reload, window, document) => {
     ({ get }, reload, window, document) => {
         let toHtml = html`
         let toHtml = html`
         <main>
         <main>
-            <!-- __BLOCK__ -->
+            <!--[if BLOCK]><![endif]-->
             <div>foo<input></div>
             <div>foo<input></div>
             <div>baz<input></div>
             <div>baz<input></div>
-            <!-- __ENDBLOCK__ -->
+            <!--[if ENDBLOCK]><![endif]-->
             <div>bar<input></div>
             <div>bar<input></div>
         </main>
         </main>
         `
         `
@@ -419,23 +442,23 @@ test('can morph with conditional markers',
 test('can morph with flat-nested conditional markers',
 test('can morph with flat-nested conditional markers',
     [html`
     [html`
         <main>
         <main>
-            <!-- __BLOCK__ -->
+            <!--[if BLOCK]><![endif]-->
             <div>foo<input></div>
             <div>foo<input></div>
-            <!-- __BLOCK__ -->
-            <!-- __ENDBLOCK__ -->
-            <!-- __ENDBLOCK__ -->
+            <!--[if BLOCK]><![endif]-->
+            <!--[if ENDBLOCK]><![endif]-->
+            <!--[if ENDBLOCK]><![endif]-->
             <div>bar<input></div>
             <div>bar<input></div>
         </main>
         </main>
     `],
     `],
     ({ get }, reload, window, document) => {
     ({ get }, reload, window, document) => {
         let toHtml = html`
         let toHtml = html`
         <main>
         <main>
-            <!-- __BLOCK__ -->
+            <!--[if BLOCK]><![endif]-->
             <div>foo<input></div>
             <div>foo<input></div>
-            <!-- __BLOCK__ -->
-            <!-- __ENDBLOCK__ -->
+            <!--[if BLOCK]><![endif]-->
+            <!--[if ENDBLOCK]><![endif]-->
             <div>baz<input></div>
             <div>baz<input></div>
-            <!-- __ENDBLOCK__ -->
+            <!--[if ENDBLOCK]><![endif]-->
             <div>bar<input></div>
             <div>bar<input></div>
         </main>
         </main>
         `
         `
@@ -470,3 +493,47 @@ test('can morph @event handlers', [
         get('button').should(haveText('buzz'));
         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`
     [html`
         <div
         <div
             x-data="{
             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/focus/dist/cdn.js"></script>
     <script src="/../../packages/intersect/dist/cdn.js"></script>
     <script src="/../../packages/intersect/dist/cdn.js"></script>
     <script src="/../../packages/collapse/dist/cdn.js"></script>
     <script src="/../../packages/collapse/dist/cdn.js"></script>
+    <script src="/../../packages/anchor/dist/cdn.js"></script>
     <script src="/../../packages/mask/dist/cdn.js"></script>
     <script src="/../../packages/mask/dist/cdn.js"></script>
     <script src="/../../packages/ui/dist/cdn.js"></script>
     <script src="/../../packages/ui/dist/cdn.js"></script>
     <script>
     <script>