Răsfoiți Sursa

Add Portals (#2424)

* wip

* wip

* Add documentation for portals

* Make portal beta

* wip

* wip
Caleb Porzio 3 ani în urmă
părinte
comite
09951d6b58

+ 1 - 0
index.html

@@ -4,6 +4,7 @@
     <script src="./packages/history/dist/cdn.js"></script>
     <script src="./packages/persist/dist/cdn.js"></script>
     <script src="./packages/trap/dist/cdn.js"></script>
+    <script src="./packages/portal/dist/cdn.js"></script>
     <script src="./packages/alpinejs/dist/cdn.js" defer></script>
     <!-- <script src="https://unpkg.com/alpinejs@3.0.0/dist/cdn.min.js" defer></script> -->
 

+ 6 - 3
packages/alpinejs/src/alpine.js

@@ -1,9 +1,9 @@
 import { setReactivityEngine, disableEffectScheduling, reactive, effect, release, raw } from './reactivity'
+import { mapAttributes, directive, setPrefix as prefix, prefix as prefixed } from './directives'
+import { start, addRootSelector, addInitSelector, closestRoot, initTree } from './lifecycle'
 import { mutateDom, deferMutations, flushAndStopDeferringMutations } from './mutation'
-import { mapAttributes, directive, setPrefix as prefix } from './directives'
-import { start, addRootSelector, closestRoot, initTree } from './lifecycle'
+import { mergeProxies, closestDataStack, addScopeToNode } from './scope'
 import { setEvaluator, evaluate, evaluateLater } from './evaluator'
-import { mergeProxies, closestDataStack } from './scope'
 import { transition } from './directives/x-transition'
 import { clone, skipDuringClone } from './clone'
 import { interceptor } from './interceptor'
@@ -28,6 +28,8 @@ let Alpine = {
     closestDataStack,
     skipDuringClone,
     addRootSelector,
+    addInitSelector,
+    addScopeToNode,
     deferMutations,
     mapAttributes,
     evaluateLater,
@@ -44,6 +46,7 @@ let Alpine = {
     evaluate,
     initTree,
     nextTick,
+    prefixed,
     prefix,
     plugin,
     magic,

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

@@ -169,6 +169,8 @@ let directiveOrder = [
     'show',
     'if',
     DEFAULT,
+    'portal',
+    'portal-target',
     'element',
 ]
 

+ 6 - 0
packages/alpinejs/src/directives/x-on.js

@@ -7,6 +7,12 @@ mapAttributes(startingWith('@', into(prefix('on:'))))
 
 directive('on', skipDuringClone((el, { value, modifiers, expression }, { cleanup }) => {
     let evaluate = expression ? evaluateLater(el, expression) : () => {}
+   
+    // Forward events liseners on portals.
+    if (el.tagName.toLowerCase() === 'template') {
+        if (! el._x_forwardEvents) el._x_forwardEvents = []
+        if (! el._x_forwardEvents.includes(value)) el._x_forwardEvents.push(value)
+    }
 
     let removeListener = on(el, value, modifiers, e => {
         evaluate(() => {}, { scope: { '$event': e }, params: [e] })

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

@@ -57,6 +57,9 @@ export function findClosest(el, callback) {
 
     if (callback(el)) return el
 
+    // Support crawling up portals.
+    if (el._x_portal_back) el = el._x_portal_back
+    
     if (! el.parentElement) return
 
     return findClosest(el.parentElement, callback)

+ 218 - 0
packages/docs/src/en/plugins/portal.md

@@ -0,0 +1,218 @@
+---
+order: 6
+title: Portal
+description: Send Alpine templates to other parts of the DOM
+graph_image: https://alpinejs.dev/social_portal.jpg
+---
+
+# Portal Plugin
+
+Alpine's Portal plugin allows you to transport part of your Alpine template to another part of the DOM on the page entirely.
+
+This is useful for things like modals (especially nesting them), where it's helpful to break out of the z-index of the current Alpine component.
+
+<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://unpkg.com/@alpinejs/portal@3.x.x/dist/cdn.min.js"></script>
+
+<!-- Alpine Core -->
+<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
+```
+
+### Via NPM
+
+You can install Portal from NPM for use inside your bundle like so:
+
+```shell
+npm install @alpinejs/portal
+```
+
+Then initialize it from your bundle:
+
+```js
+import Alpine from 'alpinejs'
+import portal from '@alpinejs/portal'
+
+Alpine.plugin(portal)
+
+...
+```
+
+<a name="usage"></a>
+## Usage
+
+Everytime you use a portal, you will need two different directives: `x-portal` and `x-portal-target`.
+
+By attaching `x-portal` to a `<template>` element, you are telling Alpine to send that DOM content to another template element that has a matching `x-portal-target` on it.
+
+Here's a contrived modal example using portals:
+
+```alpine
+<div x-data="{ open: false }">
+    <button @click="open = ! open">Toggle Modal</button>
+
+    <template x-portal="modals">
+        <div x-show="open">
+            Modal contents...
+        </div>
+    </template>
+
+    <div class="py-4">Some other content placed AFTER the modal markup.</div>
+</div>
+
+<template x-portal-target="modals"></template>
+```
+
+<!-- START_VERBATIM -->
+<div class="demo" x-ref="root">
+    <div x-data="{ open: false }">
+        <button @click="open = ! open">Toggle Modal</button>
+
+        <template x-portal="modals">
+            <div x-show="open">
+                Modal contents...
+            </div>
+        </template>
+
+        <div class="py-4">Some other content...</div>
+    </div>
+
+    <template x-portal-target="modals"></template>
+</div>
+<!-- END_VERBATIM -->
+
+Notice how when toggling the modal, the actual modal contents show up AFTER the "Some other content..." element? This is because when Alpine is initializing, it sees `x-portal="modals"` and takes that markup out of the page waiting until it finds an element with `x-portal-target="modals"` to insert it into.
+
+<a name="forwarding-events"></a>
+## Forwarding events
+
+Alpine tries it's best to make the experience of using portals seemless. Anything you would normally do in a template, you should be able to do inside a portal. Portal content can access the normal Alpine scope of the component as well as other features like `$refs`, `$root`, etc...
+
+However, native DOM events have no concept of portals, so if, for example, you trigger a "click" event from inside a portal, that event will bubble up the DOM tree as it normally would ignoring the fact that it is within a portal.
+
+To make this experience more seemless, you can "forward" events by simply registering event listeners on the portal's `<template>` element itself like so:
+
+```alpine
+<div x-data="{ open: false }">
+    <button @click="open = ! open">Toggle Modal</button>
+
+    <template x-portal="modals" @click="open = false">
+        <div x-show="open">
+            Modal contents...
+            (click to close)
+        </div>
+    </template>
+</div>
+
+<template x-portal-target="modals"></template>
+```
+
+<!-- START_VERBATIM -->
+<div class="demo" x-ref="root">
+    <div x-data="{ open: false }">
+        <button @click="open = ! open">Toggle Modal</button>
+
+        <template x-portal="modals" @click="open = false">
+            <div x-show="open">
+                Modal contents...<br>
+                (click to close)
+            </div>
+        </template>
+    </div>
+
+    <template x-portal-target="modals"></template>
+</div>
+<!-- END_VERBATIM -->
+
+Notice how we are now able to listen for events dispatched from within the portal from outside the portal itself?
+
+Alpine does this by looking for event listeners registered on `<template x-portal...` and stops those events from propogating past the `<template x-portal-target...` element. Then it creates a copy of that event and re-dispatches it from `<template x-portal`.
+
+<a name="nesting-portals"></a>
+## Nesting portals
+
+Portals are especially helpful if you are trying to nest one modal within another. Alpine makes it simple to do so:
+
+```alpine
+<div x-data="{ open: false }">
+    <button @click="open = ! open">Toggle Modal</button>
+
+    <template x-portal="modals">
+        <div x-show="open">
+            Modal contents...
+            
+            <div x-data="{ open: false }">
+                <button @click="open = ! open">Toggle Nested Modal</button>
+
+                <template x-portal="modals">
+                    <div x-show="open">
+                        Nested modal contents...
+                    </div>
+                </template>
+            </div>
+        </div>
+    </template>
+</div>
+
+<template x-portal-target="modals"></template>
+```
+
+<!-- START_VERBATIM -->
+<div class="demo" x-ref="root">
+    <div x-data="{ open: false }">
+        <button @click="open = ! open">Toggle Modal</button>
+
+        <template x-portal="modals">
+            <div x-show="open">
+                <div class="py-4">Modal contents...</div>
+                
+                <div x-data="{ open: false }">
+                    <button @click="open = ! open">Toggle Nested Modal</button>
+
+                    <template x-portal="modals">
+                        <div class="pt-4" x-show="open">
+                            Nested modal contents...
+                        </div>
+                    </template>
+                </div>
+            </div>
+        </template>
+    </div>
+
+    <template x-portal-target="modals"></template>
+</div>
+<!-- END_VERBATIM -->
+
+After toggling "on" both modals, they are authored as children, but will be rendered as sibling elements on the page, not within one another.
+
+<a name="multiple-portals"></a>
+## Handling multiple portals
+
+Suppose you have multiple modals on a page, but a single `<template x-portal-target="modal">` element.
+
+Alpine automatically appends extra elements with `x-portal="modals"` at the target. No need for any extra syntax:
+
+```alpine
+<template x-portal="modals">
+    ...
+</template>
+
+<template x-portal="modals">
+    ...
+</template>
+
+...
+
+<template x-portal-target="modals"></template>
+```
+
+Now both of these modals will be rendered where `<template x-portal-target="modals">` lives.

+ 117 - 57
packages/morph/src/morph.js

@@ -10,24 +10,24 @@ function breakpoint(message) {
     return new Promise(resolve => resolveStep = () => resolve())
 }
 
-export async function morph(dom, toHtml, options) {
+export async function morph(from, toHtml, options) {
     assignOptions(options)
     
     let toEl = createElement(toHtml)
 
     // If there is no x-data on the element we're morphing,
     // let's seed it with the outer Alpine scope on the page.
-    if (window.Alpine && ! dom._x_dataStack) {
-        toEl._x_dataStack = window.Alpine.closestDataStack(dom)
+    if (window.Alpine && ! from._x_dataStack) {
+        toEl._x_dataStack = window.Alpine.closestDataStack(from)
         
-        toEl._x_dataStack && window.Alpine.clone(dom, toEl)
+        toEl._x_dataStack && window.Alpine.clone(from, toEl)
     }
     
     await breakpoint()
 
-    patch(dom, toEl)
+    patch(from, toEl)
 
-    return dom
+    return from
 }
 
 morph.step = () => resolveStep()
@@ -65,11 +65,15 @@ function createElement(html) {
     return document.createRange().createContextualFragment(html).firstElementChild
 }
 
-async function patch(dom, to) {
-    if (dom.isEqualNode(to)) return
+async function patch(from, to) {
+    // This is a time saver, however, it won't catch differences in nested <template> tags.
+    // I'm leaving this here as I believe it's an important speed improvement, I just
+    // don't see a way to enable it currently:
+    //
+    // if (from.isEqualNode(to)) return
    
-    if (differentElementNamesTypesOrKeys(dom, to)) {
-        let result = patchElement(dom, to)
+    if (differentElementNamesTypesOrKeys(from, to)) {
+        let result = patchElement(from, to)
         
         await breakpoint('Swap elements')
        
@@ -78,30 +82,30 @@ async function patch(dom, to) {
 
     let updateChildrenOnly = false
 
-    if (shouldSkip(updating, dom, to, () => updateChildrenOnly = true)) return
+    if (shouldSkip(updating, from, to, () => updateChildrenOnly = true)) return
 
-    window.Alpine && initializeAlpineOnTo(dom, to, () => updateChildrenOnly = true)
+    window.Alpine && initializeAlpineOnTo(from, to, () => updateChildrenOnly = true)
 
     if (textOrComment(to)) {
-        await patchNodeValue(dom, to)
-        updated(dom, to)
+        await patchNodeValue(from, to)
+        updated(from, to)
         
         return
     }
 
     if (! updateChildrenOnly) {
-        await patchAttributes(dom, to)
+        await patchAttributes(from, to)
     }
 
-    updated(dom, to)
+    updated(from, to)
 
-    await patchChildren(dom, to)
+    await patchChildren(from, to)
 }
 
-function differentElementNamesTypesOrKeys(dom, to) {
-    return dom.nodeType != to.nodeType
-        || dom.nodeName != to.nodeName
-        || getKey(dom) != getKey(to)
+function differentElementNamesTypesOrKeys(from, to) {
+    return from.nodeType != to.nodeType
+        || from.nodeName != to.nodeName
+        || getKey(from) != getKey(to)
 }
 
 function textOrComment(el) {
@@ -109,45 +113,45 @@ function textOrComment(el) {
         || el.nodeType === 8
 }
 
-function patchElement(dom, to) {
-    if (shouldSkip(removing, dom)) return
+function patchElement(from, to) {
+    if (shouldSkip(removing, from)) return
 
     let toCloned = to.cloneNode(true)
 
     if (shouldSkip(adding, toCloned)) return
 
-    dom.parentNode.replaceChild(toCloned, dom)
+    dom(from).replace(toCloned)
 
-    removed(dom)
+    removed(from)
     added(toCloned)
 }
 
-async function patchNodeValue(dom, to) {
+async function patchNodeValue(from, to) {
     let value = to.nodeValue
 
-    if (dom.nodeValue !== value) {
-        dom.nodeValue = value
+    if (from.nodeValue !== value) {
+        from.nodeValue = value
 
         await breakpoint('Change text node to: ' + value)
     }
 }
 
-async function patchAttributes(dom, to) {
-    if (dom._x_isShown && ! to._x_isShown) {
+async function patchAttributes(from, to) {
+    if (from._x_isShown && ! to._x_isShown) {
         return
     }
-    if (! dom._x_isShown && to._x_isShown) {
+    if (! from._x_isShown && to._x_isShown) {
         return
     }
 
-    let domAttributes = Array.from(dom.attributes)
+    let domAttributes = Array.from(from.attributes)
     let toAttributes = Array.from(to.attributes)
 
     for (let i = domAttributes.length - 1; i >= 0; i--) {
         let name = domAttributes[i].name;
 
         if (! to.hasAttribute(name)) {
-            dom.removeAttribute(name)
+            from.removeAttribute(name)
            
             await breakpoint('Remove attribute')
         }
@@ -157,23 +161,23 @@ async function patchAttributes(dom, to) {
         let name = toAttributes[i].name
         let value = toAttributes[i].value
 
-        if (dom.getAttribute(name) !== value) {
-            dom.setAttribute(name, value)
+        if (from.getAttribute(name) !== value) {
+            from.setAttribute(name, value)
 
             await breakpoint(`Set [${name}] attribute to: "${value}"`)
         }
     }
 }
 
-async function patchChildren(dom, to) {
-    let domChildren = dom.childNodes
+async function patchChildren(from, to) {
+    let domChildren = from.childNodes
     let toChildren = to.childNodes
 
     let toKeyToNodeMap = keyToMap(toChildren)
     let domKeyDomNodeMap = keyToMap(domChildren)
 
-    let currentTo = to.firstChild
-    let currentFrom = dom.firstChild
+    let currentTo = dom(to).nodes().first()
+    let currentFrom = dom(from).nodes().first()
 
     let domKeyHoldovers = {}
 
@@ -186,23 +190,23 @@ async function patchChildren(dom, to) {
             if (toKey && domKeyHoldovers[toKey]) {
                 let holdover = domKeyHoldovers[toKey]
 
-                dom.appendChild(holdover)
+                dom.append(from, holdover)
                 currentFrom = holdover
 
                 await breakpoint('Add element (from key)')
             } else {
-                let added = addNodeTo(currentTo, dom)
+                let added = addNodeTo(currentTo, from)
 
                 await breakpoint('Add element: ' + added.outerHTML || added.nodeValue)
 
-                currentTo = currentTo.nextSibling
+                currentTo = dom(currentTo).nodes().next()
 
                 continue
             }
         }
 
         if (lookahead) {
-            let nextToElementSibling = currentTo.nextElementSibling
+            let nextToElementSibling = dom(currentTo).next()
 
             if (nextToElementSibling && currentFrom.isEqualNode(nextToElementSibling)) {
                 currentFrom = addNodeBefore(currentTo, currentFrom)
@@ -218,8 +222,8 @@ async function patchChildren(dom, to) {
                 domKeyHoldovers[domKey] = currentFrom
                 currentFrom = addNodeBefore(currentTo, currentFrom)
                 domKeyHoldovers[domKey].remove()
-                currentFrom = currentFrom.nextSibling
-                currentTo = currentTo.nextSibling
+                currentFrom = dom(currentFrom).nodes.next()
+                currentTo = dom(currentTo).nodes.next()
 
                 await breakpoint('No "to" key')
 
@@ -228,8 +232,7 @@ async function patchChildren(dom, to) {
 
             if (toKey && ! domKey) {
                 if (domKeyDomNodeMap[toKey]) {
-                    currentFrom.parentElement.replaceChild(domKeyDomNodeMap[toKey], currentFrom)
-                    currentFrom = domKeyDomNodeMap[toKey]
+                    currentFrom = dom(currentFrom).replace(domKeyDomNodeMap[toKey])
                     
                     await breakpoint('No "from" key')
                 }
@@ -240,16 +243,15 @@ async function patchChildren(dom, to) {
                 let domKeyNode = domKeyDomNodeMap[toKey]
 
                 if (domKeyNode) {
-                    currentFrom.parentElement.replaceChild(domKeyNode, currentFrom)
-                    currentFrom = domKeyNode
+                    currentFrom = dom(currentFrom).replace(domKeyNode)
                     
                     await breakpoint('Move "from" key')
                 } else {
                     domKeyHoldovers[domKey] = currentFrom
                     currentFrom = addNodeBefore(currentTo, currentFrom)
                     domKeyHoldovers[domKey].remove()
-                    currentFrom = currentFrom.nextSibling
-                    currentTo = currentTo.nextSibling
+                    currentFrom = dom(currentFrom).next()
+                    currentTo = dom(currentTo).next()
                    
                     await breakpoint('I dont even know what this does')
                     
@@ -261,8 +263,8 @@ async function patchChildren(dom, to) {
         // Patch elements
         await patch(currentFrom, currentTo)
 
-        currentTo = currentTo && currentTo.nextSibling
-        currentFrom = currentFrom && currentFrom.nextSibling
+        currentTo = currentTo && dom(currentTo).next()
+        currentFrom = currentFrom && dom(currentFrom).next()
     }
 
     // Cleanup extra froms
@@ -270,14 +272,14 @@ async function patchChildren(dom, to) {
         if(! shouldSkip(removing, currentFrom)) {
             let domForRemoval = currentFrom
 
-            dom.removeChild(domForRemoval)
+            domForRemoval.remove()
 
             await breakpoint('remove el')
 
             removed(domForRemoval)
         }
 
-        currentFrom = currentFrom.nextSibling
+        currentFrom = dom(currentFrom).nodes().next()
     }
 }
 
@@ -311,7 +313,7 @@ function addNodeTo(node, parent) {
     if(! shouldSkip(adding, node)) {
         let clone = node.cloneNode(true)
 
-        parent.appendChild(clone);
+        dom(parent).append(clone)
 
         added(clone)
 
@@ -323,7 +325,7 @@ function addNodeBefore(node, beforeMe) {
     if(! shouldSkip(adding, node)) {
         let clone = node.cloneNode(true)
 
-        beforeMe.parentElement.insertBefore(clone, beforeMe);
+        dom(beforeMe).before(clone)
 
         added(clone)
 
@@ -343,3 +345,61 @@ function initializeAlpineOnTo(from, to, childrenOnly) {
         window.Alpine.clone(from, to)
     }
 }
+
+function dom(el) {
+    return new DomManager(el)
+}
+
+class DomManager {
+    el = undefined
+
+    constructor(el) {
+        this.el = el
+    }
+
+    traversals = {
+        'first': 'firstElementChild',
+        'next': 'nextElementSibling',
+        'parent': 'parentElement',
+    }
+
+    nodes() {
+        this.traversals = {
+            'first': 'firstChild',
+            'next': 'nextSibling',
+            'parent': 'parentNode', 
+        }; return this
+    }
+
+    first() {
+        return this.portalTo(this.el[this.traversals['first']])
+    }
+
+    next() {
+        return this.portalTo(this.portalBack(this.el[this.traversals['next']]))
+    }
+
+    before(insertee) {
+        this.el[this.traversals['parent']].insertBefore(insertee, this.el); return insertee
+    }
+
+    replace(replacement) {
+        this.el[this.traversals['parent']].replaceChild(replacement, this.el); return replacement
+    }
+    
+    append(appendee) {
+        this.el.appendChild(appendee); return appendee
+    }
+
+    portalTo(el) {
+        if (! el) return el
+        if (el._x_portal) return el._x_portal
+        return el
+    }
+
+    portalBack(el) {
+        if (! el) return el
+        if (el._x_portal_back) return el._x_portal_back
+        return el
+    }
+}

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

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

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

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

+ 11 - 0
packages/portal/package.json

@@ -0,0 +1,11 @@
+{
+    "name": "@alpinejs/portal",
+    "version": "3.6.0-beta.0",
+    "description": "Send Alpine templates to other parts of the DOM",
+    "author": "Caleb Porzio",
+    "license": "MIT",
+    "main": "dist/module.cjs.js",
+    "module": "dist/module.esm.js",
+    "unpkg": "dist/cdn.min.js",
+    "dependencies": {}
+}

+ 62 - 0
packages/portal/src/index.js

@@ -0,0 +1,62 @@
+export default function (Alpine) {
+    let portals = new MapSet
+
+    Alpine.directive('portal', (el, { expression }, { effect, cleanup }) => {
+        let clone = el.content.cloneNode(true).firstElementChild
+        // Add reference to element on <template x-portal, and visa versa.
+        el._x_portal = clone
+        clone._x_portal_back = el
+    
+        let init = (target) => {
+            // Forward event listeners:
+            if (el._x_forwardEvents) {
+                el._x_forwardEvents.forEach(eventName => {
+                    clone.addEventListener(eventName, e => {
+                        e.stopPropagation()
+                        
+                        el.dispatchEvent(new e.constructor(e.type, e))
+                    })
+                })
+            }
+    
+            Alpine.addScopeToNode(clone, {}, el)
+    
+            Alpine.mutateDom(() => {
+                target.before(clone)
+    
+                Alpine.initTree(clone)
+            })
+    
+            cleanup(() => {
+                clone.remove()
+               
+                portals.delete(expression, init) 
+            })
+        }
+    
+        portals.add(expression, init)
+    })
+    
+    Alpine.addInitSelector(() => `[${Alpine.prefixed('portal-target')}]`)
+    Alpine.directive('portal-target', (el, { expression }) => {
+        portals.each(expression, initPortal => initPortal(el))
+    })
+}
+
+class MapSet {
+    map = new Map
+
+    get(name) {
+        if (! this.map.has(name)) this.map.set(name, new Set)
+
+        return this.map.get(name)
+    }
+
+    add(name, value) { this.get(name).add(value) }
+
+    each(name, callback) { this.map.get(name).forEach(callback) }
+
+    delete(name, value) {
+        this.map.get(name).delete(value)
+    }
+}

+ 1 - 0
scripts/build.js

@@ -11,6 +11,7 @@ let brotliSize = require('brotli-size');
     'persist',
     'collapse',
     'morph',
+    'portal',
     'trap',
 ]).forEach(package => {
     if (! fs.existsSync(`./packages/${package}/dist`)) {

+ 43 - 0
tests/cypress/integration/plugins/morph.spec.js

@@ -92,3 +92,46 @@ test('morphing an element with multiple nested Alpine components preserves scope
         get('h1').should(haveText('law'))
     },
 )
+
+test('can morph portals',
+    [html`
+        <div x-data="{ count: 1 }" id="a">
+            <button @click="count++">Inc</button>
+
+            <template x-portal="foo">
+                <div>
+                    <h1 x-text="count"></h1>
+                    <h2>hey</h2>
+                </div>
+            </template>
+        </div>
+
+        <div id="b">
+            <template x-portal-target="foo"></template>
+        </div>
+    `],
+    ({ get }, reload, window, document) => {
+        let toHtml = html`
+        <div x-data="{ count: 1 }" id="a">
+            <button @click="count++">Inc</button>
+
+            <template x-portal="foo">
+                <div>
+                    <h1 x-text="count"></h1>
+                    <h2>there</h2>
+                </div>
+            </template>
+        </div>
+        `
+        get('h1').should(haveText('1'))
+        get('h2').should(haveText('hey'))
+        get('button').click()
+        get('h1').should(haveText('2'))
+        get('h2').should(haveText('hey'))
+        
+        get('div#a').then(([el]) => window.Alpine.morph(el, toHtml))
+
+        get('h1').should(haveText('2'))
+        get('h2').should(haveText('there'))
+    },
+)

+ 151 - 0
tests/cypress/integration/plugins/portal.spec.js

@@ -0,0 +1,151 @@
+import { beEqualTo, beVisible, haveText, html, notBeVisible, test } from '../../utils'
+
+test('can use a portal',
+    [html`
+        <div x-data="{ count: 1 }" id="a">
+            <button @click="count++">Inc</button>
+
+            <template x-portal="foo">
+                <span x-text="count"></span>
+            </template>
+        </div>
+
+        <div id="b">
+            <template x-portal-target="foo"></template>
+        </div>
+    `],
+    ({ get }) => {
+        get('#b span').should(haveText('1'))
+        get('button').click()
+        get('#b span').should(haveText('2'))
+    },
+)
+
+test('can send multiple to a portal',
+    [html`
+        <div x-data="{ count: 1 }" id="a">
+            <button @click="count++">Inc</button>
+
+            <template x-portal="foo">
+                <h1 x-text="count"></h1>
+            </template>
+
+            <template x-portal="foo">
+                <h2 x-text="count + 1"></h2>
+            </template>
+        </div>
+
+        <div id="b">
+            <template x-portal-target="foo"></template>
+        </div>
+    `],
+    ({ get }) => {
+        get('#b h1').should(haveText('1'))
+        get('#b h2').should(haveText('2'))
+        get('button').click()
+        get('#b h1').should(haveText('2'))
+        get('#b h2').should(haveText('3'))
+    },
+)
+
+test('portal targets forward events to portal source if listeners are attached',
+    [html`
+        <div x-data="{ count: 1 }" id="a">
+            <button @click="count++">Inc</button>
+
+            <template x-portal="foo" @click="count++">
+                <h1 x-text="count"></h1>
+            </template>
+        </div>
+
+        <div id="b">
+            <template x-portal-target="foo"></template>
+        </div>
+    `],
+    ({ get }) => {
+        get('#b h1').should(haveText('1'))
+        get('button').click()
+        get('#b h1').should(haveText('2'))
+        get('h1').click()
+        get('#b h1').should(haveText('3'))
+    },
+)
+
+test('removing portal source removes portal target',
+    [html`
+        <div x-data="{ count: 1 }" id="a">
+            <button @click="$refs.template.remove()">Remove</button>
+
+            <template x-portal="foo" @click="count++" x-ref="template">
+                <h1 x-text="count"></h1>
+            </template>
+        </div>
+
+        <div id="b">
+            <template x-portal-target="foo"></template>
+        </div>
+    `],
+    ({ get }) => {
+        get('#b h1').should(beVisible())
+        get('button').click()
+        get('#b h1').should(notBeVisible())
+    },
+)
+
+test('$refs inside portal can be accessed outside',
+    [html`
+        <div x-data="{ count: 1 }" id="a">
+            <button @click="$refs.count.remove()">Remove</button>
+
+            <template x-portal="foo">
+                <h1 x-text="count" x-ref="count"></h1>
+            </template>
+        </div>
+
+        <div id="b">
+            <template x-portal-target="foo"></template>
+        </div>
+    `],
+    ({ get }) => {
+        get('#b h1').should(beVisible())
+        get('button').click()
+        get('#b h1').should(notBeVisible())
+    },
+)
+
+test('$root is accessed outside portal',
+    [html`
+        <div x-data="{ count: 1 }" id="a">
+            <template x-portal="foo">
+                <h1 x-text="$root.id"></h1>
+            </template>
+        </div>
+
+        <div id="b">
+            <template x-portal-target="foo"></template>
+        </div>
+    `],
+    ({ get }) => {
+        get('#b h1').should(beVisible())
+        get('#b h1').should(haveText('a'))
+    },
+)
+
+test('$id honors x-id outside portal',
+    [html`
+        <div x-data="{ count: 1 }" id="a" x-id="['foo']">
+            <h1 x-text="$id('foo')"></h1>
+
+            <template x-portal="foo">
+                <h1 x-text="$id('foo')"></h1>
+            </template>
+        </div>
+
+        <div id="b">
+            <template x-portal-target="foo"></template>
+        </div>
+    `],
+    ({ get }) => {
+        get('#b h1').should(haveText('foo-1'))
+    },
+)

+ 1 - 0
tests/cypress/spec.html

@@ -8,6 +8,7 @@
 
     <script src="/../../packages/history/dist/cdn.js"></script>
     <script src="/../../packages/morph/dist/cdn.js"></script>
+    <script src="/../../packages/portal/dist/cdn.js"></script>
     <script src="/../../packages/persist/dist/cdn.js"></script>
     <script src="/../../packages/trap/dist/cdn.js"></script>
     <script src="/../../packages/intersect/dist/cdn.js"></script>