Forráskód Böngészése

Add documentation for portals

Caleb Porzio 3 éve
szülő
commit
de7e4edc01

+ 8 - 20
index.html

@@ -5,32 +5,20 @@
     <script src="./packages/persist/dist/cdn.js"></script>
     <script src="./packages/trap/dist/cdn.js" defer></script>
     <script src="./packages/collapse/dist/cdn.js" defer></script>
+    <script src="./packages/portal/dist/cdn.js" defer></script>
     <script src="./packages/alpinejs/dist/cdn.js" defer></script>
     <!-- <script src="https://cdn-tailwindcss.vercel.app/"></script> -->
     <!-- <script src="https://unpkg.com/alpinejs@3.0.0/dist/cdn.min.js" defer></script> -->
+    
+    <div x-data="{ open: false }">
+        <button @click="open = ! open">Open</button>
 
-    <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>
+        <template x-portal="modals">
+            <div x-show="open" x-transition>
+                ...
             </div>
         </template>
     </div>
 
-    <div id="b">
-        <template x-portal-target="foo"></template>
-    </div>
-
-    <footer id="morph" onclick="window.morph()">Morph</footer>
-
-    <script>
-        window.morph = function () {
-            let from = document.querySelector('#a')
-            toHtml = from.outerHTML.replace('hey', 'there')
-            window.Alpine.morph(from, toHtml)
-        }
-    </script>
+    <template x-portal-target="modals"></template>
 </html>

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

+ 0 - 1
packages/alpinejs/src/directives/index.js

@@ -14,4 +14,3 @@ import './x-ref'
 import './x-if'
 import './x-id'
 import './x-on'
-import './x-portal'

+ 0 - 65
packages/alpinejs/src/directives/x-portal.js

@@ -1,65 +0,0 @@
-import { addScopeToNode } from '../scope'
-import { directive, prefix } from '../directives'
-import { addInitSelector, initTree } from '../lifecycle'
-import { mutateDom } from '../mutation'
-
-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)
-    }
-}
-
-let portals = new MapSet
-
-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))
-                })
-            })
-        }
-
-        addScopeToNode(clone, {}, el)
-
-        mutateDom(() => {
-            target.before(clone)
-
-            initTree(clone)
-        })
-
-        cleanup(() => {
-            clone.remove()
-           
-            portals.delete(expression, init) 
-        })
-    }
-
-    portals.add(expression, init)
-})
-
-addInitSelector(() => `[${prefix('portal-target')}]`)
-directive('portal-target', (el, { expression }) => {
-    portals.each(expression, initPortal => initPortal(el))
-})

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

@@ -20,7 +20,7 @@ export function start() {
         directives(el, attrs).forEach(handle => handle())
     })
 
-    let outNestedComponents = el => ! Root(el.parentElement, true)
+    let outNestedComponents = el => ! isRoot(el.parentElement, true)
     Array.from(document.querySelectorAll(allSelectors()))
         .filter(outNestedComponents)
         .forEach(el => {
@@ -57,6 +57,8 @@ export function findClosest(el, callback) {
 
     if (callback(el)) return el
 
+    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.

+ 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.5.2",
+    "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`)) {

+ 36 - 5
tests/cypress/integration/plugins/portal.spec.js

@@ -113,8 +113,39 @@ test('$refs inside portal can be accessed outside',
     },
 )
 
-// Portal can only have one root.
-// works with transition groups
-// works with $ids
-// works with refs
-// works with root
+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>