فهرست منبع

Refactor directives into seperate files && make mutation observer more flexible

Caleb Porzio 5 سال پیش
والد
کامیت
ea3c8417a9
11فایلهای تغییر یافته به همراه418 افزوده شده و 334 حذف شده
  1. 34 307
      src/component.js
  2. 65 0
      src/directives/bind.js
  3. 95 0
      src/directives/for.js
  4. 21 0
      src/directives/if.js
  5. 43 0
      src/directives/model.js
  6. 69 0
      src/directives/on.js
  7. 17 0
      src/directives/show.js
  8. 0 0
      src/directives/text.js
  9. 6 5
      src/index.js
  10. 0 22
      src/utils.js
  11. 68 0
      test/constructor.spec.js

+ 34 - 307
src/component.js

@@ -1,4 +1,10 @@
-import { arrayUnique, walk, keyToModifier, saferEval, saferEvalNoReturn, getXAttrs, debounce, transitionIn, transitionOut, parseFor } 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) {
@@ -123,10 +129,10 @@ export default class Component {
         })
     }
 
-    initializeElements(rootEl, extraVars = {}, skipForLoopSpawns = true) {
+    initializeElements(rootEl, extraVars = {}) {
         this.walkAndSkipNestedComponents(rootEl, el => {
             // Don't touch spawns from for loop
-            if (skipForLoopSpawns && el.__x_for_key !== undefined) return false
+            if (el.__x_for_key !== undefined) return false
 
             this.initializeElement(el, extraVars)
         }, el => {
@@ -150,10 +156,10 @@ export default class Component {
         this.resolveBoundAttributes(el, true, extraVars)
     }
 
-    updateElements(rootEl, extraVars = {}, skipForLoopSpawns = true) {
+    updateElements(rootEl, extraVars = {}) {
         this.walkAndSkipNestedComponents(rootEl, 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 (skipForLoopSpawns && el.__x_for_key !== undefined && ! el.isSameNode(this.$el)) return false
+            if (el.__x_for_key !== undefined && ! el.isSameNode(this.$el)) return false
 
             this.updateElement(el, extraVars)
         }, el => {
@@ -162,28 +168,18 @@ export default class Component {
     }
 
     updateElement(el, extraVars = {}) {
-        this.resolveBoundAttributes(el, true, extraVars)
+        this.resolveBoundAttributes(el, false, extraVars)
     }
 
     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;
@@ -195,104 +191,38 @@ export default class Component {
         getXAttrs(el).forEach(({ type, value, modifiers, expression }) => {
             switch (type) {
                 case 'model':
-                    var attrName = 'value'
-                    var output = this.evaluateReturnExpression(expression, extraVars)
-                    this.updateAttributeValue(el, attrName, output)
+                    handleAttributeBindingDirective(this, el, 'value', expression, extraVars)
                     break;
 
                 case 'bind':
-                    var attrName = value
-                    //
-                    if (el.tagName.toLowerCase() === 'template' && attrName === 'key') return
-                    var output = this.evaluateReturnExpression(expression, extraVars)
-                    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, extraVars)
-                    this.updateTextValue(el, output)
+                    el.innerText = this.evaluateReturnExpression(expression, extraVars)
                     break;
 
                 case 'html':
-                    var output = this.evaluateReturnExpression(expression, extraVars)
-                    this.updateHtmlValue(el, output)
+                    el.innerHTML = this.evaluateReturnExpression(expression, extraVars)
                     break;
 
                 case 'show':
                     var output = this.evaluateReturnExpression(expression, extraVars)
-                    this.updateVisibility(el, output, initialUpdate)
+
+                    handleShowDirective(el, output, initialUpdate)
                     break;
 
                 case 'if':
                     var output = this.evaluateReturnExpression(expression, extraVars)
-                    this.updatePresence(el, output)
+
+                    handleIfDirective(el, output, initialUpdate)
                     break;
 
                 case 'for':
-                        const { single, bunch, iterator1, iterator2 } = parseFor(expression)
-
-                        var output = this.evaluateReturnExpression(bunch)
-
-                        var previousEl = el
-                        output.forEach((i, index, group) => {
-                            const nextEl = previousEl.nextElementSibling
-                            let currentEl = nextEl
-                            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
-
-                            const currentKey = keyAttr
-                                ? this.evaluateReturnExpression(keyAttr.expression, keyAliases)
-                                : index
-
-                            if (nextEl && nextEl.__x_for_key !== undefined) {
-                                // The key is not the same as the item in the dom.
-                                if (nextEl.__x_for_key !== currentKey) {
-                                    // Let's see if it's somewhere else.
-                                    var tmpCurrentEl = currentEl
-                                    while(tmpCurrentEl) {
-                                        if (tmpCurrentEl.__x_for_key === currentKey) {
-                                            el.parentElement.insertBefore(tmpCurrentEl, currentEl)
-                                            currentEl = tmpCurrentEl
-                                            break
-                                        }
-
-                                        tmpCurrentEl = (tmpCurrentEl.nextElementSibling && tmpCurrentEl.nextElementSibling.__x_for_key !== undefined) ? tmpCurrentEl.nextElementSibling : false
-                                    }
-
-                                }
-
-                                this.updateElements(currentEl, {'item': i}, false)
-                            } else {
-                                const clone = document.importNode(el.content, true);
-                                el.parentElement.insertBefore(clone, nextEl)
-
-                                currentEl = previousEl.nextElementSibling
-
-                                transitionIn(currentEl, () => {})
-
-                                this.initializeElements(currentEl, {[single]: i}, false)
-
-                                currentEl.__x_for_key = currentKey
-                            }
-
-                            previousEl = currentEl
-                        })
-
-                        // Clean up oldies
-                        var thing = (previousEl.nextElementSibling && previousEl.nextElementSibling.__x_for_key !== undefined) ? previousEl.nextElementSibling : false
-
-                        while(thing) {
-                            const thingImmutable = thing
-                            transitionOut(thing, () => {
-                                thingImmutable.remove()
-                            })
-
-                            thing = (thing.nextElementSibling && thing.nextElementSibling.__x_for_key !== undefined) ? thing.nextElementSibling : false
-                        }
-                    // this.updatePresence(el, output)
+                    handleForDirective(this, el, expression, initialUpdate)
                     break;
 
                 case 'cloak':
@@ -305,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
 
@@ -347,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, extraData) {
-        return saferEval(expression, this.$data, extraData)
-    }
-
-    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)
+    })
+}

+ 95 - 0
src/directives/for.js

@@ -0,0 +1,95 @@
+import { transitionIn, transitionOut, getXAttrs } from '../utils'
+
+export function handleForDirective(component, el, expression, initialUpdate) {
+    const { single, bunch, iterator1, iterator2 } = parseFor(expression)
+
+    var output = component.evaluateReturnExpression(bunch)
+
+    var previousEl = el
+    output.forEach((i, index, group) => {
+        const nextEl = previousEl.nextElementSibling
+        let currentEl = nextEl
+        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
+
+        const currentKey = keyAttr
+            ? component.evaluateReturnExpression(keyAttr.expression, keyAliases)
+            : index
+
+        if (nextEl && nextEl.__x_for_key !== undefined) {
+            // The key is not the same as the item in the dom.
+            if (nextEl.__x_for_key !== currentKey) {
+                // Let's see if it's somewhere else.
+                var tmpCurrentEl = currentEl
+                while(tmpCurrentEl) {
+                    if (tmpCurrentEl.__x_for_key === currentKey) {
+                        el.parentElement.insertBefore(tmpCurrentEl, currentEl)
+                        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 {
+            const clone = document.importNode(el.content, true);
+            el.parentElement.insertBefore(clone, nextEl)
+
+            currentEl = previousEl.nextElementSibling
+
+            transitionIn(currentEl, () => {}, initialUpdate)
+
+            component.initializeElements(currentEl, {[single]: i})
+
+            currentEl.__x_for_key = currentKey
+        }
+
+        previousEl = currentEl
+    })
+
+    // Clean up oldies
+    var thing = (previousEl.nextElementSibling && previousEl.nextElementSibling.__x_for_key !== undefined) ? previousEl.nextElementSibling : false
+
+    while(thing) {
+        const thingImmutable = thing
+        transitionOut(thing, () => {
+            thingImmutable.remove()
+        })
+
+        thing = (thing.nextElementSibling && thing.nextElementSibling.__x_for_key !== undefined) ? thing.nextElementSibling : false
+    }
+}
+
+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
+  }

+ 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)
                     })
                 }
               }

+ 0 - 22
src/utils.js

@@ -178,25 +178,3 @@ export function transition(el, classesDuring, classesStart, classesEnd, hook1, h
         })
     });
 }
-
-export 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
-  }

+ 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 = []