Caleb Porzio 3 år sedan
förälder
incheckning
bd0f0fc020

+ 19 - 14
index.html

@@ -1,18 +1,23 @@
 <html>
-    <script src="./packages/intersect/dist/cdn.js" defer></script>
-    <script src="./packages/morph/dist/cdn.js" defer></script>
-    <script src="./packages/history/dist/cdn.js"></script>
-    <script src="./packages/persist/dist/cdn.js"></script>
-    <script src="./packages/trap/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> -->
+<script src="./packages/intersect/dist/cdn.js" defer></script>
+<script src="./packages/morph/dist/cdn.js" defer></script>
+<script src="./packages/history/dist/cdn.js"></script>
+<script src="./packages/persist/dist/cdn.js"></script>
+<script src="./packages/trap/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> -->
+<script src="https://cdn.tailwindcss.com"></script>
 
-    <!-- Play around. -->
-    <div x-data="{ open: false }">
-        <button @click="open = !open">Toggle</button>
+<div x-data="{ yo: true, }">
+    <button :class="['bg-blue-500', 'font-bold', yo && 'tracking-wide']" @click="yo = ! yo">first</button> 
+    
+    <template x-teleport="#portal">
+        <button>second</button> 
+    </template>
+
+    <button>third</button> 
+</div>
+
+<div id="portal"></div>
 
-        <span x-show="open">
-            Content...
-        </span>
-    </div>
 </html>

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

@@ -1,12 +1,13 @@
 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 { start, addRootSelector, addInitSelector, closestRoot, findClosest, initTree } from './lifecycle'
 import { mutateDom, deferMutations, flushAndStopDeferringMutations } from './mutation'
-import { mergeProxies, closestDataStack, addScopeToNode } from './scope'
+import { mergeProxies, closestDataStack, addScopeToNode, scope as $data } from './scope'
 import { setEvaluator, evaluate, evaluateLater } from './evaluator'
 import { transition } from './directives/x-transition'
 import { clone, skipDuringClone } from './clone'
 import { interceptor } from './interceptor'
+import { getBinding as bound } from './utils/bind'
 import { debounce } from './utils/debounce'
 import { throttle } from './utils/throttle'
 import { setStyles } from './utils/styles'
@@ -14,6 +15,7 @@ import { nextTick } from './nextTick'
 import { plugin } from './plugin'
 import { magic } from './magics'
 import { store } from './store'
+import { bind } from './binds'
 import { data } from './datas'
 
 let Alpine = {
@@ -35,6 +37,7 @@ let Alpine = {
     evaluateLater,
     setEvaluator,
     mergeProxies,
+    findClosest,
     closestRoot,
     interceptor, // INTERNAL: not public API and is subject to change without major release.
     transition, // INTERNAL
@@ -53,7 +56,10 @@ let Alpine = {
     store,
     start,
     clone,
+    bound,
+    $data,
     data,
+    bind,
 }
 
 export default Alpine

+ 20 - 0
packages/alpinejs/src/binds.js

@@ -0,0 +1,20 @@
+
+let binds = {}
+
+export function bind(name, object) {
+    binds[name] = typeof object !== 'function' ? () => object : object
+}
+
+export function injectBindingProviders(obj) {
+    Object.entries(binds).forEach(([name, callback]) => {
+        Object.defineProperty(obj, name, {
+            get() {
+                return (...args) => {
+                    return callback(...args)
+                }
+            }
+        })
+    })
+
+    return obj
+}

+ 9 - 3
packages/alpinejs/src/directives/x-bind.js

@@ -1,12 +1,16 @@
 import { attributesOnly, directive, directives, into, mapAttributes, prefix, startingWith } from '../directives'
+import { addInitSelector } from '../lifecycle'
 import { evaluateLater } from '../evaluator'
 import { mutateDom } from '../mutation'
 import bind from '../utils/bind'
+import { injectBindingProviders } from '../binds'
 
 mapAttributes(startingWith(':', into(prefix('bind:'))))
 
 directive('bind', (el, { value, modifiers, expression, original }, { effect }) => {
-    if (! value) return applyBindingsObject(el, expression, original, effect)
+    if (! value) {
+        return applyBindingsObject(el, expression, original, effect)
+    }
 
     if (value === 'key') return storeKeyForXFor(el, expression)
 
@@ -21,6 +25,9 @@ directive('bind', (el, { value, modifiers, expression, original }, { effect }) =
 })
 
 function applyBindingsObject(el, expression, original, effect) {
+    let bindingProviders = {}
+    injectBindingProviders(bindingProviders)
+   
     let getBindings = evaluateLater(el, expression)
 
     let cleanupRunners = []
@@ -50,8 +57,7 @@ function applyBindingsObject(el, expression, original, effect) {
 
                 handle()
             })
-        })
-
+        }, { scope: bindingProviders } )
     })
 }
 

+ 2 - 4
packages/alpinejs/src/magics/$data.js

@@ -1,6 +1,4 @@
-import { closestDataStack, mergeProxies } from '../scope'
+import { scope } from '../scope'
 import { magic } from '../magics'
 
-magic('data', el => {
-    return mergeProxies(closestDataStack(el))
-})
+magic('data', el => scope(el))

+ 22 - 0
packages/alpinejs/src/utils/bind.js

@@ -7,6 +7,8 @@ export default function bind(el, name, value, modifiers = []) {
     if (! el._x_bindings) el._x_bindings = reactive({})
 
     el._x_bindings[name] = value
+   
+    if (el._x_omit_attributes && el._x_omit_attributes.includes(name)) return
 
     name = modifiers.includes('camel') ? camelCase(name) : name
 
@@ -127,3 +129,23 @@ function isBooleanAttr(attrName) {
 function attributeShouldntBePreservedIfFalsy(name) {
     return ! ['aria-pressed', 'aria-checked', 'aria-expanded'].includes(name)
 }
+
+export function getBinding(el, name, fallback) {
+    // First let's get it out of Alpine bound data. 
+    if (el._x_bindings && el._x_bindings[name]) return el._x_bindings[name]
+
+    // If not, we'll return the literal attribute. 
+    let attr = el.getAttribute(name)
+
+    // Nothing bound:
+    if (attr === null) return fallback
+   
+    if (isBooleanAttr(name)) {
+        return !! [name, 'true'].includes(attr)
+    }
+
+    // The case of a custom attribute with no value. Ex: <div manual> 
+    if (attr === '') return true
+   
+    return attr
+}

+ 157 - 1
packages/trap/src/index.js

@@ -1,6 +1,162 @@
-import { createFocusTrap } from 'focus-trap';
+import { createFocusTrap } from 'focus-trap'
+import { focusable, tabbable, isFocusable } from 'tabbable'
 
 export default function (Alpine) {
+    let lastFocused
+    let currentFocused 
+
+    window.addEventListener('focusin', () => {
+        lastFocused = currentFocused
+        currentFocused = document.activeElement
+    })
+
+    Alpine.directive('teleport-focus', (el) => {
+        let lastTarget = document.activeElement
+
+        document.addEventListener('focusin', e => {
+            // Let's check if we just crossed over the <template> portal tag.
+            if (
+                (el.compareDocumentPosition(e.target) & Node.DOCUMENT_POSITION_FOLLOWING
+                && el.compareDocumentPosition(lastTarget) & Node.DOCUMENT_POSITION_PRECEDING)
+                || (el.compareDocumentPosition(e.target) & Node.DOCUMENT_POSITION_PRECEDING
+                    && el.compareDocumentPosition(lastTarget) & Node.DOCUMENT_POSITION_FOLLOWING)
+            ) {
+                let els = tabbable(el._x_teleport, { includeContainer: true, displayCheck: 'none' })
+                // If there is a focusable, focus it, otherwise, bail and let it be past the <template>.
+                if (els[0]) {
+                    queueMicrotask(() => {
+                        el._x_teleport._x_return_focus_to = e.target 
+                        els[0].focus()
+
+                        el._x_teleport.addEventListener('keydown', function portalListener(e) {
+                            if (e.key.toLowerCase() !== 'tab') return
+                            // We are tabbing away from something focusable.
+                            if (! document.activeElement.isSameNode(e.target)) return
+            
+                            let els = focusable(el._x_teleport, { includeContainer: true })
+                            let last = els.slice(-1)[0]
+            
+                            if (last && last.isSameNode(e.target)) {
+                                el._x_teleport._x_return_focus_to.focus()
+                                e.preventDefault() 
+                                e.stopPropagation() 
+                                el._x_teleport.removeEventListener('keydown', portalListener)
+                            }
+                        })
+                    })
+                }
+            } 
+
+            lastTarget = e.target
+        })
+
+        // document.addEventListener('focusin', e => {
+        //     // If focusing is happening outside the teleported content.
+        //     if (! el._x_teleport.contains(e.target)) {
+        //         // AND the last focused el was inside teleport
+        //         if (el._x_teleport.contains(lastTarget)) {
+        //             lastOutsideTarget.focus()
+        //         } else {
+        //             // OTHERWISE, we know we are still outside the portal.
+        //             if (
+        //                 el.compareDocumentPosition(e.target) & Node.DOCUMENT_POSITION_FOLLOWING
+        //                 && el.compareDocumentPosition(lastOutsideTarget) & Node.DOCUMENT_POSITION_PRECEDING
+        //             ) {
+        //                 // If we did, intercept this focus, and instead focus the first focusable inside the portal.
+
+        //                 let els = tabbable(el._x_teleport, { includeContainer: true, displayCheck: 'none' })
+        //                 // If there is a focusable, focus it, otherwise, bail and let it be past the <template>.
+        //                 if (els[0]) {
+        //                     doFocus = () => els[0].focus()
+        //                 }
+        //             } 
+        //         }
+
+        //         lastOutsideTarget = e.target
+        //     }
+           
+        //     lastTarget = e.target
+
+        //     doFocus()
+        //     doFocus = () => {}
+        // })
+    })
+
+    Alpine.magic('focus', el => {
+        let within = el
+       
+        return {
+            __noscroll: false, 
+            __wrapAround: false, 
+            within(el) { within = el; return this },
+            withoutScrolling() { this.__noscroll = true; return this },
+            withWrapAround() { this.__wrapAround = true; return this },
+            focusable(el) {
+                return isFocusable(el)
+            },
+            previouslyFocused() {
+                return lastFocused
+            },
+            all() {
+                if (Array.isArray(within)) return within 
+
+                return focusable(within, { displayCheck: 'none' })
+            },
+            isFirst(el) {
+                let els = this.all() 
+                
+                return els[0] && els[0].isSameNode(el)
+            },
+            isLast(el) {
+                let els = this.all() 
+                
+                return els.length && els.slice(-1)[0].isSameNode(el)
+            },
+            getFirst() { return this.all()[0] },
+            getLast() { return this.all(f).slice(-1)[0] },
+            first() { this.focus(this.getFirst()) },
+            last() { this.focus(this.getLast()) },
+            next() {
+                let list = this.all()
+                let current = document.activeElement
+
+                // Can't find currently focusable element in list.
+                if (list.indexOf(current) === -1) return
+
+                // This is the last element in the list and we want to wrap around.
+                if (this.__wrapAround && list.indexOf(current) === list.length - 1) {
+                    return this.focus(list[0])
+                }
+
+                this.focus(list[list.indexOf(current) + 1])
+            },
+            prev() { return this.previous() },
+            previous() {
+                let list = this.all()
+                let current = document.activeElement
+
+                // Can't find currently focusable element in list.
+                if (list.indexOf(current) === -1) return
+
+                // This is the first element in the list and we want to wrap around.
+                if (this.__wrapAround && list.indexOf(current) === 0) {
+                    return this.focus(list.slice(-1)[0])
+                }
+
+                this.focus(list[list.indexOf(current) - 1])
+            },
+            focus(el, wrapEl) {
+                if (! el) return 
+
+                setTimeout(() => {
+                    if (! el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0')
+
+                    el.focus({ preventScroll: this._noscroll })
+                })
+            }
+        }
+    })
+
     Alpine.directive('trap', Alpine.skipDuringClone(
         (el, { expression, modifiers }, { effect, evaluateLater }) => {
             let evaluator = evaluateLater(expression)

+ 46 - 0
tests/cypress/integration/custom-bind.spec.js

@@ -0,0 +1,46 @@
+import { haveText, html, test } from '../utils'
+
+test('can register custom bind object',
+    html`
+        <script>
+            document.addEventListener('alpine:init', () => {
+                Alpine.bind('Foo', {
+                    'x-init'() { this.$el.innerText = 'bar' },
+                })
+            })
+        </script>
+
+        <div x-data x-bind="Foo"></div>
+    `,
+    ({ get }) => get('div').should(haveText('bar'))
+)
+
+test('can register custom bind as function',
+    html`
+        <script>
+            document.addEventListener('alpine:init', () => {
+                Alpine.bind('Foo', () => ({
+                    'x-init'() { this.$el.innerText = 'bar' },
+                }))
+            })
+        </script>
+
+        <div x-data x-bind="Foo"></div>
+    `,
+    ({ get }) => get('div').should(haveText('bar'))
+)
+
+test('can consume custom bind as function',
+    html`
+        <script>
+            document.addEventListener('alpine:init', () => {
+                Alpine.bind('Foo', (subject) => ({
+                    'x-init'() { this.$el.innerText = subject },
+                }))
+            })
+        </script>
+
+        <div x-data x-bind="Foo('bar')"></div>
+    `,
+    ({ get }) => get('div').should(haveText('bar'))
+)