浏览代码

Merge pull request #92 from alpinejs/x-for

Add x-for
Caleb Porzio 5 年之前
父节点
当前提交
17f60c9e63
共有 15 个文件被更改,包括 685 次插入257 次删除
  1. 0 0
      dist/alpine.js
  2. 0 0
      dist/alpine.js.map
  3. 46 0
      index.html
  4. 57 250
      src/component.js
  5. 65 0
      src/directives/bind.js
  6. 115 0
      src/directives/for.js
  7. 21 0
      src/directives/if.js
  8. 43 0
      src/directives/model.js
  9. 69 0
      src/directives/on.js
  10. 17 0
      src/directives/show.js
  11. 0 0
      src/directives/text.js
  12. 6 5
      src/index.js
  13. 2 2
      src/utils.js
  14. 68 0
      test/constructor.spec.js
  15. 176 0
      test/for.spec.js

文件差异内容过多而无法显示
+ 0 - 0
dist/alpine.js


文件差异内容过多而无法显示
+ 0 - 0
dist/alpine.js.map


+ 46 - 0
index.html

@@ -199,6 +199,52 @@
                     </td>
                 </tr>
 
+                <tr>
+                    <td>Append Nested DOM</td>
+                    <td>
+                        <div id="goHere2">Click me.</div>
+                        <script>
+                            const thing2 = document.querySelector('#goHere2')
+                            const handler2 = (e) => {
+                                thing2.removeEventListener('click', handler2)
+
+                                e.target.innerHTML = `
+                                    <article>
+                                        <div x-data="{hey: 'there'}">
+                                            <input type="text" x-model="hey"><h1 x-text="hey"></h1>
+                                        </div>
+                                    </article>
+                                `
+                            }
+
+                            var listener = thing2.addEventListener('click', handler2)
+                        </script>
+                    </td>
+                </tr>
+
+                <tr>
+                    <td>x-for</td>
+                    <td>
+                        <div x-data="{ items: ['foo', 'bar'], foo: 'bar' }">
+                            <input type="checkbox" x-model="items" value="foo">
+                            <input type="checkbox" x-model="items" value="bar">
+                            <input type="checkbox" x-model="items" value="baz">
+
+                            <button @click="items = ['bar', 'bob']">hey</button>
+
+                            <template x-for="item in items" :key="item">
+                                <div x-text="item" x-transition:enter-start="opacity-0 scale-90"
+                                    x-transition:enter="ease-out transition-medium"
+                                    x-transition:enter-end="opacity-100 scale-100"
+                                    x-transition:leave-start="opacity-100 scale-100"
+                                    x-transition:leave="ease-in transition-faster"
+                                    x-transition:leave-end="opacity-0 scale-90"
+                                ></div>
+                            </template>
+                        </div>
+                    </td>
+                </tr>
+
                 <tr>
                     <td>Transitions</td>
                     <td>

+ 57 - 250
src/component.js

@@ -1,4 +1,10 @@
-import { arrayUnique, walk, keyToModifier, saferEval, saferEvalNoReturn, getXAttrs, debounce, transitionIn, transitionOut } from './utils'
+import { walk, saferEval, saferEvalNoReturn, getXAttrs, debounce } from './utils'
+import { handleForDirective } from './directives/for'
+import { handleAttributeBindingDirective } from './directives/bind'
+import { handleShowDirective } from './directives/show'
+import { handleIfDirective } from './directives/if'
+import { registerModelListener } from './directives/model'
+import { registerListener } from './directives/on'
 
 export default class Component {
     constructor(el) {
@@ -119,60 +125,61 @@ export default class Component {
                 }
             }
 
-            callback(el)
+            return callback(el)
         })
     }
 
-    initializeElements(rootEl) {
+    initializeElements(rootEl, extraVars = {}) {
         this.walkAndSkipNestedComponents(rootEl, el => {
-            this.initializeElement(el)
+            // Don't touch spawns from for loop
+            if (el.__x_for_key !== undefined) return false
+
+            this.initializeElement(el, extraVars)
         }, el => {
             el.__x = new Component(el)
         })
+
+        // Walk through the $nextTick stack and clear it as we go.
+        while (this.nextTickStack.length > 0) {
+            this.nextTickStack.shift()()
+        }
     }
 
-    initializeElement(el) {
+    initializeElement(el, extraVars = {}) {
         // To support class attribute merging, we have to know what the element's
         // original class attribute looked like for reference.
         if (el.hasAttribute('class') && getXAttrs(el).length > 0) {
             el.__originalClasses = el.getAttribute('class').split(' ')
         }
 
-        this.registerListeners(el)
-        this.resolveBoundAttributes(el, true)
+        this.registerListeners(el, extraVars)
+        this.resolveBoundAttributes(el, true, extraVars)
     }
 
-    updateElements(rootEl) {
+    updateElements(rootEl, extraVars = {}) {
         this.walkAndSkipNestedComponents(rootEl, el => {
-            this.updateElement(el)
+            // Don't touch spawns from for loop (and check if the root is actually a for loop in a parent, don't skip it.)
+            if (el.__x_for_key !== undefined && ! el.isSameNode(this.$el)) return false
+
+            this.updateElement(el, extraVars)
         }, el => {
             el.__x = new Component(el)
         })
     }
 
-    updateElement(el) {
-        this.resolveBoundAttributes(el)
+    updateElement(el, extraVars = {}) {
+        this.resolveBoundAttributes(el, false, extraVars)
     }
 
-    registerListeners(el) {
+    registerListeners(el, extraVars = {}) {
         getXAttrs(el).forEach(({ type, value, modifiers, expression }) => {
             switch (type) {
                 case 'on':
-                    var event = value
-                    this.registerListener(el, event, modifiers, expression)
+                    registerListener(this, el, value, modifiers, expression)
                     break;
 
                 case 'model':
-                    // If the element we are binding to is a select, a radio, or checkbox
-                    // we'll listen for the change event instead of the "input" event.
-                    var event = (el.tagName.toLowerCase() === 'select')
-                        || ['checkbox', 'radio'].includes(el.type)
-                        || modifiers.includes('lazy')
-                        ? 'change' : 'input'
-
-                    const listenerExpression = this.generateExpressionForXModelListener(el, modifiers, expression)
-
-                    this.registerListener(el, event, modifiers, listenerExpression)
+                    registerModelListener(this, el, modifiers, expression)
                     break;
                 default:
                     break;
@@ -180,39 +187,42 @@ export default class Component {
         })
     }
 
-    resolveBoundAttributes(el, initialUpdate = false) {
+    resolveBoundAttributes(el, initialUpdate = false, extraVars) {
         getXAttrs(el).forEach(({ type, value, modifiers, expression }) => {
             switch (type) {
                 case 'model':
-                    var attrName = 'value'
-                    var output = this.evaluateReturnExpression(expression)
-                    this.updateAttributeValue(el, attrName, output)
+                    handleAttributeBindingDirective(this, el, 'value', expression, extraVars)
                     break;
 
                 case 'bind':
-                    var attrName = value
-                    var output = this.evaluateReturnExpression(expression)
-                    this.updateAttributeValue(el, attrName, output)
+                    // The :key binding on an x-for is special, ignore it.
+                    if (el.tagName.toLowerCase() === 'template' && value === 'key') return
+
+                    handleAttributeBindingDirective(this, el, value, expression, extraVars)
                     break;
 
                 case 'text':
-                    var output = this.evaluateReturnExpression(expression)
-                    this.updateTextValue(el, output)
+                    el.innerText = this.evaluateReturnExpression(expression, extraVars)
                     break;
 
                 case 'html':
-                    var output = this.evaluateReturnExpression(expression)
-                    this.updateHtmlValue(el, output)
+                    el.innerHTML = this.evaluateReturnExpression(expression, extraVars)
                     break;
 
                 case 'show':
-                    var output = this.evaluateReturnExpression(expression)
-                    this.updateVisibility(el, output, initialUpdate)
+                    var output = this.evaluateReturnExpression(expression, extraVars)
+
+                    handleShowDirective(el, output, initialUpdate)
                     break;
 
                 case 'if':
-                    var output = this.evaluateReturnExpression(expression)
-                    this.updatePresence(el, output)
+                    var output = this.evaluateReturnExpression(expression, extraVars)
+
+                    handleIfDirective(el, output, initialUpdate)
+                    break;
+
+                case 'for':
+                    handleForDirective(this, el, expression, initialUpdate)
                     break;
 
                 case 'cloak':
@@ -225,6 +235,14 @@ export default class Component {
         })
     }
 
+    evaluateReturnExpression(expression, extraData) {
+        return saferEval(expression, this.$data, extraData)
+    }
+
+    evaluateCommandExpression(expression, extraData) {
+        saferEvalNoReturn(expression, this.$data, extraData)
+    }
+
     listenForNewElementsToInitialize() {
         const targetNode = this.$el
 
@@ -267,217 +285,6 @@ export default class Component {
         observer.observe(targetNode, observerOptions);
     }
 
-    generateExpressionForXModelListener(el, modifiers, dataKey) {
-        var rightSideOfExpression = ''
-        if (el.type === 'checkbox') {
-            // If the data we are binding to is an array, toggle it's value inside the array.
-            if (Array.isArray(this.$data[dataKey])) {
-                rightSideOfExpression = `$event.target.checked ? ${dataKey}.concat([$event.target.value]) : ${dataKey}.filter(i => i !== $event.target.value)`
-            } else {
-                rightSideOfExpression = `$event.target.checked`
-            }
-        } else if (el.tagName.toLowerCase() === 'select' && el.multiple) {
-            rightSideOfExpression = modifiers.includes('number')
-                ? 'Array.from($event.target.selectedOptions).map(option => { return parseFloat(option.value || option.text) })'
-                : 'Array.from($event.target.selectedOptions).map(option => { return option.value || option.text })'
-        } else {
-            rightSideOfExpression = modifiers.includes('number')
-                ? 'parseFloat($event.target.value)'
-                : (modifiers.includes('trim') ? '$event.target.value.trim()' : '$event.target.value')
-        }
-
-        if (el.type === 'radio') {
-            // Radio buttons only work properly when they share a name attribute.
-            // People might assume we take care of that for them, because
-            // they already set a shared "x-model" attribute.
-            if (! el.hasAttribute('name')) el.setAttribute('name', dataKey)
-        }
-
-        return `${dataKey} = ${rightSideOfExpression}`
-    }
-
-    registerListener(el, event, modifiers, expression) {
-        if (modifiers.includes('away')) {
-            const handler = e => {
-                // Don't do anything if the click came form the element or within it.
-                if (el.contains(e.target)) return
-
-                // Don't do anything if this element isn't currently visible.
-                if (el.offsetWidth < 1 && el.offsetHeight < 1) return
-
-                // Now that we are sure the element is visible, AND the click
-                // is from outside it, let's run the expression.
-                this.runListenerHandler(expression, e)
-
-                if (modifiers.includes('once')) {
-                    document.removeEventListener(event, handler)
-                }
-            }
-
-            // Listen for this event at the root level.
-            document.addEventListener(event, handler)
-        } else {
-            const listenerTarget = modifiers.includes('window')
-                ? window : (modifiers.includes('document') ? document : el)
-
-            const handler = e => {
-                const keyModifiers = modifiers.filter(i => i !== 'window').filter(i => i !== 'document')
-
-                // The user is scoping the keydown listener to a specific key using modifiers.
-                if (event === 'keydown' && keyModifiers.length > 0) {
-                    // The user is listening for a specific key.
-                    if (keyModifiers.length === 1 && ! keyModifiers.includes(keyToModifier(e.key))) return
-
-                    // The user is listening for key combinations.
-                    const systemKeyModifiers = ['ctrl', 'shift', 'alt', 'meta', 'cmd', 'super']
-                    const selectedSystemKeyModifiers = systemKeyModifiers.filter(modifier => keyModifiers.includes(modifier))
-
-                    if (selectedSystemKeyModifiers.length > 0) {
-                        const activelyPressedKeyModifiers = selectedSystemKeyModifiers.filter(modifier => {
-                            // Alias "cmd" and "super" to "meta"
-                            if (modifier === 'cmd' || modifier === 'super') modifier = 'meta'
-
-                            return e[`${modifier}Key`]
-                        })
-
-                        if (activelyPressedKeyModifiers.length === 0) return
-                    }
-                }
-
-                if (modifiers.includes('prevent')) e.preventDefault()
-                if (modifiers.includes('stop')) e.stopPropagation()
-
-                this.runListenerHandler(expression, e)
-
-                if (modifiers.includes('once')) {
-                    listenerTarget.removeEventListener(event, handler)
-                }
-            }
-
-            listenerTarget.addEventListener(event, handler)
-        }
-    }
-
-    runListenerHandler(expression, e) {
-        this.evaluateCommandExpression(expression, {
-            '$event': e,
-        })
-    }
-
-    evaluateReturnExpression(expression) {
-        return saferEval(expression, this.$data)
-    }
-
-    evaluateCommandExpression(expression, extraData) {
-        saferEvalNoReturn(expression, this.$data, extraData)
-    }
-
-    updateTextValue(el, value) {
-        el.innerText = value
-    }
-
-    updateHtmlValue(el, value) {
-        el.innerHTML = value
-    }
-
-    updateVisibility(el, value, initialUpdate = false) {
-        if (! value) {
-            transitionOut(el, () => {
-                el.style.display = 'none'
-            }, initialUpdate)
-        } else {
-            transitionIn(el, () => {
-                if (el.style.length === 1 && el.style.display !== '') {
-                    el.removeAttribute('style')
-                } else {
-                    el.style.removeProperty('display')
-                }
-            }, initialUpdate)
-        }
-    }
-
-    updatePresence(el, expressionResult) {
-        if (el.nodeName.toLowerCase() !== 'template') console.warn(`Alpine: [x-if] directive should only be added to <template> tags.`)
-
-        const elementHasAlreadyBeenAdded = el.nextElementSibling && el.nextElementSibling.__x_inserted_me === true
-
-        if (expressionResult && ! elementHasAlreadyBeenAdded) {
-            const clone = document.importNode(el.content, true);
-
-            el.parentElement.insertBefore(clone, el.nextElementSibling)
-
-            el.nextElementSibling.__x_inserted_me = true
-
-            transitionIn(el.nextElementSibling, () => {})
-        } else if (! expressionResult && elementHasAlreadyBeenAdded) {
-            transitionOut(el.nextElementSibling, () => {
-                el.nextElementSibling.remove()
-            })
-        }
-    }
-
-    updateAttributeValue(el, attrName, value) {
-        if (attrName === 'value') {
-            if (el.type === 'radio') {
-                el.checked = el.value == value
-            } else if (el.type === 'checkbox') {
-                if (Array.isArray(value)) {
-                    // I'm purposely not using Array.includes here because it's
-                    // strict, and because of Numeric/String mis-casting, I
-                    // want the "includes" to be "fuzzy".
-                    let valueFound = false
-                    value.forEach(val => {
-                        if (val == el.value) {
-                            valueFound = true
-                        }
-                    })
-
-                    el.checked = valueFound
-                } else {
-                    el.checked = !! value
-                }
-            } else if (el.tagName === 'SELECT') {
-                this.updateSelect(el, value)
-            } else {
-                el.value = value
-            }
-        } else if (attrName === 'class') {
-            if (Array.isArray(value)) {
-                const originalClasses = el.__originalClasses || []
-                el.setAttribute('class', arrayUnique(originalClasses.concat(value)).join(' '))
-            } else if (typeof value === 'object') {
-                Object.keys(value).forEach(classNames => {
-                    if (value[classNames]) {
-                        classNames.split(' ').forEach(className => el.classList.add(className))
-                    } else {
-                        classNames.split(' ').forEach(className => el.classList.remove(className))
-                    }
-                })
-            } else {
-                const originalClasses = el.__originalClasses || []
-                const newClasses = value.split(' ')
-                el.setAttribute('class', arrayUnique(originalClasses.concat(newClasses)).join(' '))
-            }
-        } else if (['disabled', 'readonly', 'required', 'checked', 'hidden'].includes(attrName)) {
-            // Boolean attributes have to be explicitly added and removed, not just set.
-            if (!! value) {
-                el.setAttribute(attrName, '')
-            } else {
-                el.removeAttribute(attrName)
-            }
-        } else {
-            el.setAttribute(attrName, value)
-        }
-    }
-
-    updateSelect(el, value) {
-        const arrayWrappedValue = [].concat(value).map(value => { return value + '' })
-
-        Array.from(el.options).forEach(option => {
-            option.selected = arrayWrappedValue.includes(option.value || option.text)
-        })
-    }
-
     getRefsProxy() {
         var self = this
 

+ 65 - 0
src/directives/bind.js

@@ -0,0 +1,65 @@
+import { arrayUnique } from '../utils'
+
+export function handleAttributeBindingDirective(component, el, attrName, expression, extraVars) {
+    var value = component.evaluateReturnExpression(expression, extraVars)
+
+    if (attrName === 'value') {
+        if (el.type === 'radio') {
+            el.checked = el.value == value
+        } else if (el.type === 'checkbox') {
+            if (Array.isArray(value)) {
+                // I'm purposely not using Array.includes here because it's
+                // strict, and because of Numeric/String mis-casting, I
+                // want the "includes" to be "fuzzy".
+                let valueFound = false
+                value.forEach(val => {
+                    if (val == el.value) {
+                        valueFound = true
+                    }
+                })
+
+                el.checked = valueFound
+            } else {
+                el.checked = !! value
+            }
+        } else if (el.tagName === 'SELECT') {
+            updateSelect(el, value)
+        } else {
+            el.value = value
+        }
+    } else if (attrName === 'class') {
+        if (Array.isArray(value)) {
+            const originalClasses = el.__originalClasses || []
+            el.setAttribute('class', arrayUnique(originalClasses.concat(value)).join(' '))
+        } else if (typeof value === 'object') {
+            Object.keys(value).forEach(classNames => {
+                if (value[classNames]) {
+                    classNames.split(' ').forEach(className => el.classList.add(className))
+                } else {
+                    classNames.split(' ').forEach(className => el.classList.remove(className))
+                }
+            })
+        } else {
+            const originalClasses = el.__originalClasses || []
+            const newClasses = value.split(' ')
+            el.setAttribute('class', arrayUnique(originalClasses.concat(newClasses)).join(' '))
+        }
+    } else if (['disabled', 'readonly', 'required', 'checked', 'hidden'].includes(attrName)) {
+        // Boolean attributes have to be explicitly added and removed, not just set.
+        if (!! value) {
+            el.setAttribute(attrName, '')
+        } else {
+            el.removeAttribute(attrName)
+        }
+    } else {
+        el.setAttribute(attrName, value)
+    }
+}
+
+function updateSelect(el, value) {
+    const arrayWrappedValue = [].concat(value).map(value => { return value + '' })
+
+    Array.from(el.options).forEach(option => {
+        option.selected = arrayWrappedValue.includes(option.value || option.text)
+    })
+}

+ 115 - 0
src/directives/for.js

@@ -0,0 +1,115 @@
+import { transitionIn, transitionOut, getXAttrs } from '../utils'
+
+export function handleForDirective(component, el, expression, initialUpdate) {
+    const { single, bunch, iterator1, iterator2 } = parseFor(expression)
+
+    var items = component.evaluateReturnExpression(bunch)
+
+    // As we walk the array, we'll also walk the DOM (updating/creating as we go).
+    var previousEl = el
+    items.forEach((i, index, group) => {
+        const currentKey = getThisIterationsKeyFromTemplateTag(component, el, single, iterator1, iterator2, i, index, group)
+        let currentEl = previousEl.nextElementSibling
+
+        // Let's check and see if the x-for has already generated an element last time it ran.
+        if (currentEl && currentEl.__x_for_key !== undefined) {
+            // If the the key's don't match.
+            if (currentEl.__x_for_key !== currentKey) {
+                // We'll look ahead to see if we can find it further down.
+                var tmpCurrentEl = currentEl
+                while(tmpCurrentEl) {
+                    // If we found it later in the DOM.
+                    if (tmpCurrentEl.__x_for_key === currentKey) {
+                        // Move it to where it's supposed to be in the DOM.
+                        el.parentElement.insertBefore(tmpCurrentEl, currentEl)
+                        // And set it as the current element as if we just created it.
+                        currentEl = tmpCurrentEl
+                        break
+                    }
+
+                    tmpCurrentEl = (tmpCurrentEl.nextElementSibling && tmpCurrentEl.nextElementSibling.__x_for_key !== undefined) ? tmpCurrentEl.nextElementSibling : false
+                }
+            }
+
+            // Temporarily remove the key indicator to allow the normal "updateElements" to work
+            delete currentEl.__x_for_key
+
+            component.updateElements(currentEl, {'item': i})
+
+            // Reset it for next time around.
+            currentEl.__x_for_key = currentKey
+        } else {
+            // There are no more .__x_for_key elements, meaning the page is first loading, OR, there are
+            // extra items in the array that need to be added as new elements.
+
+            // Let's create a clone from the template.
+            const clone = document.importNode(el.content, true);
+            // Insert it where we are in the DOM.
+            el.parentElement.insertBefore(clone, currentEl)
+
+            // Set it as the current element.
+            currentEl = previousEl.nextElementSibling
+
+            // And transition it in if it's not the first page load.
+            transitionIn(currentEl, () => {}, initialUpdate)
+
+            // Now, let's walk the new DOM node and initialize everything,
+            // including new nested components.
+            component.initializeElements(currentEl, {[single]: i})
+
+            currentEl.__x_for_key = currentKey
+        }
+
+        previousEl = currentEl
+    })
+
+    // Now that we've added/updated/moved all the elements for the current state of the loop.
+    // Anything left over, we can get rid of.
+    var nextElementFromOldLoop = (previousEl.nextElementSibling && previousEl.nextElementSibling.__x_for_key !== undefined) ? previousEl.nextElementSibling : false
+
+    while(nextElementFromOldLoop) {
+        const nextElementFromOldLoopImmutable = nextElementFromOldLoop
+
+        transitionOut(nextElementFromOldLoop, () => {
+            nextElementFromOldLoopImmutable.remove()
+        })
+
+        nextElementFromOldLoop = (nextElementFromOldLoop.nextElementSibling && nextElementFromOldLoop.nextElementSibling.__x_for_key !== undefined) ? nextElementFromOldLoop.nextElementSibling : false
+    }
+}
+
+// This was taken from VueJS 2.* core. Thanks Vue!
+function parseFor (expression) {
+    const forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/
+    const stripParensRE = /^\(|\)$/g
+    const forAliasRE = /([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/
+
+    const inMatch = expression.match(forAliasRE)
+    if (! inMatch) return
+    const res = {}
+    res.bunch = inMatch[2].trim()
+    const single = inMatch[1].trim().replace(stripParensRE, '')
+    const iteratorMatch = single.match(forIteratorRE)
+    if (iteratorMatch) {
+        res.single = single.replace(forIteratorRE, '').trim()
+        res.iterator1 = iteratorMatch[1].trim()
+        if (iteratorMatch[2]) {
+            res.iterator2 = iteratorMatch[2].trim()
+        }
+    } else {
+        res.single = single
+    }
+    return res
+  }
+
+function getThisIterationsKeyFromTemplateTag(component, el, single, iterator1, iterator2, i, index, group) {
+    const keyAttr = getXAttrs(el, 'bind').filter(attr => attr.value === 'key')[0]
+
+    let keyAliases = { [single]: i }
+    if (iterator1) keyAliases[iterator1] = index
+    if (iterator2) keyAliases[iterator2] = group
+
+    return keyAttr
+        ? component.evaluateReturnExpression(keyAttr.expression, keyAliases)
+        : index
+}

+ 21 - 0
src/directives/if.js

@@ -0,0 +1,21 @@
+import { transitionIn, transitionOut } from '../utils'
+
+export function handleIfDirective(el, expressionResult, initialUpdate) {
+    if (el.nodeName.toLowerCase() !== 'template') console.warn(`Alpine: [x-if] directive should only be added to <template> tags.`)
+
+    const elementHasAlreadyBeenAdded = el.nextElementSibling && el.nextElementSibling.__x_inserted_me === true
+
+    if (expressionResult && ! elementHasAlreadyBeenAdded) {
+        const clone = document.importNode(el.content, true);
+
+        el.parentElement.insertBefore(clone, el.nextElementSibling)
+
+        el.nextElementSibling.__x_inserted_me = true
+
+        transitionIn(el.nextElementSibling, () => {}, initialUpdate)
+    } else if (! expressionResult && elementHasAlreadyBeenAdded) {
+        transitionOut(el.nextElementSibling, () => {
+            el.nextElementSibling.remove()
+        }, initialUpdate)
+    }
+}

+ 43 - 0
src/directives/model.js

@@ -0,0 +1,43 @@
+import { registerListener } from './on'
+
+export function registerModelListener(component, el, modifiers, expression) {
+    // If the element we are binding to is a select, a radio, or checkbox
+    // we'll listen for the change event instead of the "input" event.
+    var event = (el.tagName.toLowerCase() === 'select')
+        || ['checkbox', 'radio'].includes(el.type)
+        || modifiers.includes('lazy')
+        ? 'change' : 'input'
+
+    const listenerExpression = modelListenerExpression(component, el, modifiers, expression)
+
+    registerListener(component, el, event, modifiers, listenerExpression)
+}
+
+function modelListenerExpression(component, el, modifiers, dataKey) {
+    var rightSideOfExpression = ''
+    if (el.type === 'checkbox') {
+        // If the data we are binding to is an array, toggle it's value inside the array.
+        if (Array.isArray(component.$data[dataKey])) {
+            rightSideOfExpression = `$event.target.checked ? ${dataKey}.concat([$event.target.value]) : ${dataKey}.filter(i => i !== $event.target.value)`
+        } else {
+            rightSideOfExpression = `$event.target.checked`
+        }
+    } else if (el.tagName.toLowerCase() === 'select' && el.multiple) {
+        rightSideOfExpression = modifiers.includes('number')
+            ? 'Array.from($event.target.selectedOptions).map(option => { return parseFloat(option.value || option.text) })'
+            : 'Array.from($event.target.selectedOptions).map(option => { return option.value || option.text })'
+    } else {
+        rightSideOfExpression = modifiers.includes('number')
+            ? 'parseFloat($event.target.value)'
+            : (modifiers.includes('trim') ? '$event.target.value.trim()' : '$event.target.value')
+    }
+
+    if (el.type === 'radio') {
+        // Radio buttons only work properly when they share a name attribute.
+        // People might assume we take care of that for them, because
+        // they already set a shared "x-model" attribute.
+        if (! el.hasAttribute('name')) el.setAttribute('name', dataKey)
+    }
+
+    return `${dataKey} = ${rightSideOfExpression}`
+}

+ 69 - 0
src/directives/on.js

@@ -0,0 +1,69 @@
+import { keyToModifier } from '../utils'
+
+export function registerListener(component, el, event, modifiers, expression) {
+    if (modifiers.includes('away')) {
+        const handler = e => {
+            // Don't do anything if the click came form the element or within it.
+            if (el.contains(e.target)) return
+
+            // Don't do anything if this element isn't currently visible.
+            if (el.offsetWidth < 1 && el.offsetHeight < 1) return
+
+            // Now that we are sure the element is visible, AND the click
+            // is from outside it, let's run the expression.
+            runListenerHandler(component, expression, e)
+
+            if (modifiers.includes('once')) {
+                document.removeEventListener(event, handler)
+            }
+        }
+
+        // Listen for this event at the root level.
+        document.addEventListener(event, handler)
+    } else {
+        const listenerTarget = modifiers.includes('window')
+            ? window : (modifiers.includes('document') ? document : el)
+
+        const handler = e => {
+            const keyModifiers = modifiers.filter(i => i !== 'window').filter(i => i !== 'document')
+
+            // The user is scoping the keydown listener to a specific key using modifiers.
+            if (event === 'keydown' && keyModifiers.length > 0) {
+                // The user is listening for a specific key.
+                if (keyModifiers.length === 1 && ! keyModifiers.includes(keyToModifier(e.key))) return
+
+                // The user is listening for key combinations.
+                const systemKeyModifiers = ['ctrl', 'shift', 'alt', 'meta', 'cmd', 'super']
+                const selectedSystemKeyModifiers = systemKeyModifiers.filter(modifier => keyModifiers.includes(modifier))
+
+                if (selectedSystemKeyModifiers.length > 0) {
+                    const activelyPressedKeyModifiers = selectedSystemKeyModifiers.filter(modifier => {
+                        // Alias "cmd" and "super" to "meta"
+                        if (modifier === 'cmd' || modifier === 'super') modifier = 'meta'
+
+                        return e[`${modifier}Key`]
+                    })
+
+                    if (activelyPressedKeyModifiers.length === 0) return
+                }
+            }
+
+            if (modifiers.includes('prevent')) e.preventDefault()
+            if (modifiers.includes('stop')) e.stopPropagation()
+
+            runListenerHandler(component, expression, e)
+
+            if (modifiers.includes('once')) {
+                listenerTarget.removeEventListener(event, handler)
+            }
+        }
+
+        listenerTarget.addEventListener(event, handler)
+    }
+}
+
+function runListenerHandler(component, expression, e) {
+    component.evaluateCommandExpression(expression, {
+        '$event': e,
+    })
+}

+ 17 - 0
src/directives/show.js

@@ -0,0 +1,17 @@
+import { transitionIn, transitionOut } from '../utils'
+
+export function handleShowDirective(el, value, initialUpdate = false) {
+    if (! value) {
+        transitionOut(el, () => {
+            el.style.display = 'none'
+        }, initialUpdate)
+    } else {
+        transitionIn(el, () => {
+            if (el.style.length === 1 && el.style.display !== '') {
+                el.removeAttribute('style')
+            } else {
+                el.style.removeProperty('display')
+            }
+        }, initialUpdate)
+    }
+}

+ 0 - 0
src/directives/text.js


+ 6 - 5
src/index.js

@@ -32,8 +32,8 @@ const Alpine = {
         })
     },
 
-    discoverUninitializedComponents: function (callback) {
-        const rootEls = document.querySelectorAll('[x-data]');
+    discoverUninitializedComponents: function (callback, el = null) {
+        const rootEls = (el || document).querySelectorAll('[x-data]');
 
         Array.from(rootEls)
             .filter(el => el.__x === undefined)
@@ -60,10 +60,11 @@ const Alpine = {
 
                         // Discard any changes happening within an existing component.
                         // They will take care of themselves.
-                        if (node.closest('[x-data]')) return
+                        if (node.parentElement.closest('[x-data]')) return
 
-                        // This is a new top-level component.
-                        if (node.matches('[x-data]')) callback(node)
+                        this.discoverUninitializedComponents((el) => {
+                            this.initializeComponent(el)
+                        }, node.parentElement)
                     })
                 }
               }

+ 2 - 2
src/utils.js

@@ -84,7 +84,7 @@ export function saferEvalNoReturn(expression, dataContext, additionalHelperVaria
 export function isXAttr(attr) {
     const name = replaceAtAndColonWithStandardSyntax(attr.name)
 
-    const xAttrRE = /x-(on|bind|data|text|html|model|if|show|cloak|transition|ref)/
+    const xAttrRE = /x-(on|bind|data|text|html|model|if|for|show|cloak|transition|ref)/
 
     return xAttrRE.test(name)
 }
@@ -95,7 +95,7 @@ export function getXAttrs(el, type) {
         .map(attr => {
             const name = replaceAtAndColonWithStandardSyntax(attr.name)
 
-            const typeMatch = name.match(/x-(on|bind|data|text|html|model|if|show|cloak|transition|ref)/)
+            const typeMatch = name.match(/x-(on|bind|data|text|html|model|if|for|show|cloak|transition|ref)/)
             const valueMatch = name.match(/:([a-zA-Z\-:]+)/)
             const modifiers = name.match(/\.[^.\]]+(?=[^\]]*$)/g) || []
 

+ 68 - 0
test/constructor.spec.js

@@ -1,6 +1,74 @@
 import Alpine from 'alpinejs'
 import { fireEvent, wait } from '@testing-library/dom'
 
+test('auto-detect new components at the top level', async () => {
+    var runObservers = []
+
+    global.MutationObserver = class {
+        constructor(callback) { runObservers.push(callback) }
+        observe() {}
+    }
+
+    document.body.innerHTML = `
+        <section></section>
+    `
+
+    Alpine.start()
+
+    document.querySelector('section').innerHTML = `
+        <div x-data="{ foo: '' }">
+            <input x-model="foo">
+            <span x-text="foo"></span>
+        </div>
+    `
+    runObservers[0]([
+        {
+            target: document.querySelector('section'),
+            type: 'childList',
+            addedNodes: [ document.querySelector('div') ],
+        }
+    ])
+
+    fireEvent.input(document.querySelector('input'), { target: { value: 'bar' }})
+
+    await wait(() => { expect(document.querySelector('span').innerText).toEqual('bar') })
+})
+
+test('auto-detect newsted new components at the top level', async () => {
+    var runObservers = []
+
+    global.MutationObserver = class {
+        constructor(callback) { runObservers.push(callback) }
+        observe() {}
+    }
+
+    document.body.innerHTML = `
+        <section></section>
+    `
+
+    Alpine.start()
+
+    document.querySelector('section').innerHTML = `
+        <article>
+            <div x-data="{ foo: '' }">
+                <input x-model="foo">
+                <span x-text="foo"></span>
+            </div>
+        </article>
+    `
+    runObservers[0]([
+        {
+            target: document.querySelector('section'),
+            type: 'childList',
+            addedNodes: [ document.querySelector('article') ],
+        }
+    ])
+
+    fireEvent.input(document.querySelector('input'), { target: { value: 'bar' }})
+
+    await wait(() => { expect(document.querySelector('span').innerText).toEqual('bar') })
+})
+
 test('auto-detect new components and dont lose state of existing ones', async () => {
     var runObservers = []
 

+ 176 - 0
test/for.spec.js

@@ -0,0 +1,176 @@
+import Alpine from 'alpinejs'
+import { wait } from '@testing-library/dom'
+
+global.MutationObserver = class {
+    observe() {}
+}
+
+test('x-for', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ items: ['foo'] }">
+            <button x-on:click="items = ['foo', 'bar']"></button>
+
+            <template x-for="item in items">
+                <span x-text="item"></span>
+            </template>
+        </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelectorAll('span').length).toEqual(1)
+    expect(document.querySelectorAll('span')[0].innerText).toEqual('foo')
+
+    document.querySelector('button').click()
+
+    await wait(() => { expect(document.querySelectorAll('span').length).toEqual(2) })
+
+    expect(document.querySelectorAll('span')[0].innerText).toEqual('foo')
+    expect(document.querySelectorAll('span')[1].innerText).toEqual('bar')
+})
+
+test('removes all elements when array is empty', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ items: ['foo'] }">
+            <button x-on:click="items = []"></button>
+
+            <template x-for="item in items">
+                <span x-text="item"></span>
+            </template>
+        </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelectorAll('span').length).toEqual(1)
+
+    document.querySelector('button').click()
+
+    await wait(() => { expect(document.querySelectorAll('span').length).toEqual(0) })
+})
+
+test('elements inside of loop are reactive', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ items: ['first'], foo: 'bar' }">
+            <button x-on:click="foo = 'baz'"></button>
+
+            <template x-for="item in items">
+                <span>
+                    <h1 x-text="item"></h1>
+                    <h2 x-text="foo"></h2>
+                </span>
+            </template>
+        </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelectorAll('span').length).toEqual(1)
+    expect(document.querySelector('h1').innerText).toEqual('first')
+    expect(document.querySelector('h2').innerText).toEqual('bar')
+
+    document.querySelector('button').click()
+
+    await wait(() => {
+        expect(document.querySelector('h1').innerText).toEqual('first')
+        expect(document.querySelector('h2').innerText).toEqual('baz')
+    })
+})
+
+test('components inside of loop are reactive', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ items: ['first'] }">
+            <template x-for="item in items">
+                <div x-data="{foo: 'bar'}" class="child">
+                    <span x-text="foo"></span>
+                    <button x-on:click="foo = 'bob'"></button>
+                </div>
+            </template>
+        </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelectorAll('div.child').length).toEqual(1)
+    expect(document.querySelector('span').innerText).toEqual('bar')
+
+    document.querySelector('button').click()
+
+    await wait(() => {
+        expect(document.querySelector('span').innerText).toEqual('bob')
+    })
+})
+
+test('components inside a plain element of loop are reactive', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ items: ['first'] }">
+            <template x-for="item in items">
+                <ul>
+                    <div x-data="{foo: 'bar'}" class="child">
+                        <span x-text="foo"></span>
+                        <button x-on:click="foo = 'bob'"></button>
+                    </div>
+                </ul>
+            </template>
+        </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelectorAll('ul').length).toEqual(1)
+    expect(document.querySelector('span').innerText).toEqual('bar')
+
+    document.querySelector('button').click()
+
+    await wait(() => {
+        expect(document.querySelector('span').innerText).toEqual('bob')
+    })
+})
+
+test('adding key attribute moves dom nodes properly', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ items: ['foo', 'bar'] }">
+            <button x-on:click="items = ['bar', 'foo', 'baz']"></button>
+
+            <template x-for="item in items" :key="item">
+                <span x-text="item"></span>
+            </template>
+        </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelectorAll('span').length).toEqual(2)
+    const itemA = document.querySelectorAll('span')[0]
+    itemA.setAttribute('order', 'first')
+    const itemB = document.querySelectorAll('span')[1]
+    itemB.setAttribute('order', 'second')
+
+    document.querySelector('button').click()
+
+    await wait(() => { expect(document.querySelectorAll('span').length).toEqual(3) })
+
+    expect(document.querySelectorAll('span')[0].getAttribute('order')).toEqual('second')
+    expect(document.querySelectorAll('span')[1].getAttribute('order')).toEqual('first')
+    expect(document.querySelectorAll('span')[2].getAttribute('order')).toEqual(null)
+})
+
+test('can key by index', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ items: ['foo', 'bar'] }">
+            <button x-on:click="items = ['bar', 'foo', 'baz']"></button>
+
+            <template x-for="(item, index) in items" :key="index">
+                <span x-text="item"></span>
+            </template>
+        </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelectorAll('span').length).toEqual(2)
+
+    document.querySelector('button').click()
+
+    await wait(() => { expect(document.querySelectorAll('span').length).toEqual(3) })
+})

部分文件因为文件数量过多而无法显示