1
0
Эх сурвалжийг харах

Merge pull request #2 from alpinejs/master

Update
Ryan Chandler 5 жил өмнө
parent
commit
19ce3c3671

+ 11 - 1
README.md

@@ -290,6 +290,11 @@ This will add or remove the `disabled` attribute when `myVar` is true or false r
 
 Boolean attributes are supported as per the [HTML specification](https://html.spec.whatwg.org/multipage/indices.html#attributes-3:boolean-attribute), for example `disabled`, `readonly`, `required`, `checked`, `hidden`, `selected`, `open`, etc.
 
+**`.camel` modifier**
+**Example:** `<svg x-bind:view-box.camel="viewBox">`
+
+The `camel` modifier will bind to the camel case equivalent of the attribute name. In the example above, the value of `viewBox` will be bound the `viewBox` attribute as opposed to the `view-box` attribute.
+
 ---
 
 ### `x-on`
@@ -374,6 +379,11 @@ If you wish to customize this, you can specifiy a custom wait time like so:
 <input x-on:input.debounce.750ms="fetchSomething()">
 ```
 
+**`.camel` modifier**
+**Example:** `<input x-on:event-name.camel="doSomething()">`
+
+The `camel` modifier will attach an event listener for the camel case equivalent event name. In the example above, the expression will be evaluated when the `eventName` event is fired on the element.
+
 ---
 
 ### `x-model`
@@ -562,7 +572,7 @@ These behave exactly like VueJs's transition directives, except they have differ
 </script>
 ```
 
-`x-spread` allows you to extract an elements Alpine bindings into a reusable object.
+`x-spread` allows you to extract an element's Alpine bindings into a reusable object.
 
 The object keys are the directives (Can be any directive including modifiers), and the values are callbacks to be evaluated by Alpine.
 

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 576 - 153
dist/alpine-ie11.js


+ 116 - 40
dist/alpine.js

@@ -80,6 +80,9 @@
   function kebabCase(subject) {
     return subject.replace(/([a-z])([A-Z])/g, '$1-$2').replace(/[_\s]/, '-').toLowerCase();
   }
+  function camelCase(subject) {
+    return subject.toLowerCase().replace(/[^a-zA-Z0-9]+(.)/g, (match, char) => char.toUpperCase());
+  }
   function walk(el, callback) {
     if (callback(el) === false) return;
     let node = el.firstElementChild;
@@ -113,7 +116,7 @@
   }
   function saferEvalNoReturn(expression, dataContext, additionalHelperVariables = {}) {
     if (typeof expression === 'function') {
-      return expression.call(dataContext);
+      return expression.call(dataContext, additionalHelperVariables['$event']);
     } // For the cases when users pass only a function reference to the caller: `x-on:click="foo"`
     // Where "foo" is a function. Also, we'll pass the function the event instance when we call it.
 
@@ -147,10 +150,16 @@
       })));
     }
 
-    return directives.filter(i => {
-      // If no type is passed in for filtering, bypass filter
-      if (!type) return true;
-      return i.type === type;
+    if (type) return directives.filter(i => i.type === type);
+    return sortDirectives(directives);
+  }
+
+  function sortDirectives(directives) {
+    let directiveOrder = ['bind', 'model', 'show', 'catch-all'];
+    return directives.sort((a, b) => {
+      let typeA = directiveOrder.indexOf(a.type) === -1 ? 'catch-all' : a.type;
+      let typeB = directiveOrder.indexOf(b.type) === -1 ? 'catch-all' : b.type;
+      return directiveOrder.indexOf(typeA) - directiveOrder.indexOf(typeB);
     });
   }
 
@@ -188,8 +197,18 @@
   function convertClassStringToArray(classList, filterFn = Boolean) {
     return classList.split(' ').filter(filterFn);
   }
+  const TRANSITION_TYPE_IN = 'in';
+  const TRANSITION_TYPE_OUT = 'out';
   function transitionIn(el, show, component, forceSkip = false) {
+    // We don't want to transition on the initial page load.
     if (forceSkip) return show();
+
+    if (el.__x_transition && el.__x_transition.type === TRANSITION_TYPE_IN) {
+      // there is already a similar transition going on, this was probably triggered by
+      // a change in a different property, let's just leave the previous one doing its job
+      return;
+    }
+
     const attrs = getXAttrs(el, component, 'transition');
     const showAttr = getXAttrs(el, component, 'show')[0]; // If this is triggered by a x-show.transition.
 
@@ -201,7 +220,7 @@
 
       modifiers = settingBothSidesOfTransition ? modifiers.filter((i, index) => index < modifiers.indexOf('out')) : modifiers;
       transitionHelperIn(el, modifiers, show); // Otherwise, we can assume x-transition:enter.
-    } else if (attrs.filter(attr => ['enter', 'enter-start', 'enter-end'].includes(attr.value)).length > 0) {
+    } else if (attrs.some(attr => ['enter', 'enter-start', 'enter-end'].includes(attr.value))) {
       transitionClassesIn(el, component, attrs, show);
     } else {
       // If neither, just show that damn thing.
@@ -211,6 +230,13 @@
   function transitionOut(el, hide, component, forceSkip = false) {
     // We don't want to transition on the initial page load.
     if (forceSkip) return hide();
+
+    if (el.__x_transition && el.__x_transition.type === TRANSITION_TYPE_OUT) {
+      // there is already a similar transition going on, this was probably triggered by
+      // a change in a different property, let's just leave the previous one doing its job
+      return;
+    }
+
     const attrs = getXAttrs(el, component, 'transition');
     const showAttr = getXAttrs(el, component, 'show')[0];
 
@@ -220,7 +246,7 @@
       const settingBothSidesOfTransition = modifiers.includes('in') && modifiers.includes('out');
       modifiers = settingBothSidesOfTransition ? modifiers.filter((i, index) => index > modifiers.indexOf('out')) : modifiers;
       transitionHelperOut(el, modifiers, settingBothSidesOfTransition, hide);
-    } else if (attrs.filter(attr => ['leave', 'leave-start', 'leave-end'].includes(attr.value)).length > 0) {
+    } else if (attrs.some(attr => ['leave', 'leave-start', 'leave-end'].includes(attr.value))) {
       transitionClassesOut(el, component, attrs, hide);
     } else {
       hide();
@@ -240,7 +266,7 @@
         scale: 100
       }
     };
-    transitionHelper(el, modifiers, showCallback, () => {}, styleValues);
+    transitionHelper(el, modifiers, showCallback, () => {}, styleValues, TRANSITION_TYPE_IN);
   }
   function transitionHelperOut(el, modifiers, settingBothSidesOfTransition, hideCallback) {
     // Make the "out" transition .5x slower than the "in". (Visually better)
@@ -259,7 +285,7 @@
         scale: modifierValue(modifiers, 'scale', 95)
       }
     };
-    transitionHelper(el, modifiers, () => {}, hideCallback, styleValues);
+    transitionHelper(el, modifiers, () => {}, hideCallback, styleValues, TRANSITION_TYPE_OUT);
   }
 
   function modifierValue(modifiers, key, fallback) {
@@ -292,8 +318,14 @@
     return rawValue;
   }
 
-  function transitionHelper(el, modifiers, hook1, hook2, styleValues) {
-    // If the user set these style values, we'll put them back when we're done with them.
+  function transitionHelper(el, modifiers, hook1, hook2, styleValues, type) {
+    // clear the previous transition if exists to avoid caching the wrong styles
+    if (el.__x_transition) {
+      cancelAnimationFrame(el.__x_transition.nextFrame);
+      el.__x_transition.callback && el.__x_transition.callback();
+    } // If the user set these style values, we'll put them back when we're done with them.
+
+
     const opacityCache = el.style.opacity;
     const transformCache = el.style.transform;
     const transformOriginCache = el.style.transformOrigin; // If no modifiers are present: x-show.transition, we'll default to both opacity and scale.
@@ -340,7 +372,7 @@
       }
 
     };
-    transition(el, stages);
+    transition(el, stages, type);
   }
   function transitionClassesIn(el, component, directives, showCallback) {
     let ensureStringExpression = expression => {
@@ -356,7 +388,7 @@
     const enterEnd = convertClassStringToArray(ensureStringExpression((directives.find(i => i.value === 'enter-end') || {
       expression: ''
     }).expression));
-    transitionClasses(el, enter, enterStart, enterEnd, showCallback, () => {});
+    transitionClasses(el, enter, enterStart, enterEnd, showCallback, () => {}, TRANSITION_TYPE_IN);
   }
   function transitionClassesOut(el, component, directives, hideCallback) {
     const leave = convertClassStringToArray((directives.find(i => i.value === 'leave') || {
@@ -368,9 +400,15 @@
     const leaveEnd = convertClassStringToArray((directives.find(i => i.value === 'leave-end') || {
       expression: ''
     }).expression);
-    transitionClasses(el, leave, leaveStart, leaveEnd, () => {}, hideCallback);
+    transitionClasses(el, leave, leaveStart, leaveEnd, () => {}, hideCallback, TRANSITION_TYPE_OUT);
   }
-  function transitionClasses(el, classesDuring, classesStart, classesEnd, hook1, hook2) {
+  function transitionClasses(el, classesDuring, classesStart, classesEnd, hook1, hook2, type) {
+    // clear the previous transition if exists to avoid caching the wrong classes
+    if (el.__x_transition) {
+      cancelAnimationFrame(el.__x_transition.nextFrame);
+      el.__x_transition.callback && el.__x_transition.callback();
+    }
+
     const originalClasses = el.__x_original_classes || [];
     const stages = {
       start() {
@@ -401,12 +439,31 @@
       }
 
     };
-    transition(el, stages);
-  }
-  function transition(el, stages) {
+    transition(el, stages, type);
+  }
+  function transition(el, stages, type) {
+    el.__x_transition = {
+      // Set transition type so we can avoid clearing transition if the direction is the same
+      type: type,
+      // create a callback for the last stages of the transition so we can call it
+      // from different point and early terminate it. Once will ensure that function
+      // is only called one time.
+      callback: once(() => {
+        stages.hide(); // Adding an "isConnected" check, in case the callback
+        // removed the element from the DOM.
+
+        if (el.isConnected) {
+          stages.cleanup();
+        }
+
+        delete el.__x_transition;
+      }),
+      // This store the next animation frame so we can cancel it
+      nextFrame: null
+    };
     stages.start();
     stages.during();
-    requestAnimationFrame(() => {
+    el.__x_transition.nextFrame = requestAnimationFrame(() => {
       // Note: Safari's transitionDuration property will list out comma separated transition durations
       // for every single transition property. Let's grab the first one and call it a day.
       let duration = Number(getComputedStyle(el).transitionDuration.replace(/,.*/, '').replace('s', '')) * 1000;
@@ -416,22 +473,25 @@
       }
 
       stages.show();
-      requestAnimationFrame(() => {
-        stages.end(); // Assign current transition to el in case we need to force it.
-
-        setTimeout(() => {
-          stages.hide(); // Adding an "isConnected" check, in case the callback
-          // removed the element from the DOM.
-
-          if (el.isConnected) {
-            stages.cleanup();
-          }
-        }, duration);
+      el.__x_transition.nextFrame = requestAnimationFrame(() => {
+        stages.end();
+        setTimeout(el.__x_transition.callback, duration);
       });
     });
   }
   function isNumeric(subject) {
     return !isNaN(subject);
+  } // Thanks @vuejs
+  // https://github.com/vuejs/vue/blob/4de4649d9637262a9b007720b59f80ac72a5620c/src/shared/util.js
+
+  function once(callback) {
+    let called = false;
+    return function () {
+      if (!called) {
+        called = true;
+        callback.apply(this, arguments);
+      }
+    };
   }
 
   function handleForDirective(component, templateEl, expression, initialUpdate, extraVars) {
@@ -551,7 +611,7 @@
     }
   }
 
-  function handleAttributeBindingDirective(component, el, attrName, expression, extraVars, attrType) {
+  function handleAttributeBindingDirective(component, el, attrName, expression, extraVars, attrType, modifiers) {
     var value = component.evaluateReturnExpression(el, expression, extraVars);
 
     if (attrName === 'value') {
@@ -612,7 +672,8 @@
         el.setAttribute('class', arrayUnique(originalClasses.concat(newClasses)).join(' '));
       }
     } else {
-      // If an attribute's bound value is null, undefined or false, remove the attribute
+      attrName = modifiers.includes('camel') ? camelCase(attrName) : attrName; // If an attribute's bound value is null, undefined or false, remove the attribute
+
       if ([null, undefined, false].includes(value)) {
         el.removeAttribute(attrName);
       } else {
@@ -674,9 +735,12 @@
 
     const handle = resolve => {
       if (value) {
-        transitionIn(el, () => {
-          show();
-        }, component);
+        if (el.style.display === 'none' || el.__x_transition) {
+          transitionIn(el, () => {
+            show();
+          }, component);
+        }
+
         resolve(() => {});
       } else {
         if (el.style.display !== 'none') {
@@ -715,7 +779,7 @@
     warnIfMalformedTemplate(el, 'x-if');
     const elementHasAlreadyBeenAdded = el.nextElementSibling && el.nextElementSibling.__x_inserted_me === true;
 
-    if (expressionResult && !elementHasAlreadyBeenAdded) {
+    if (expressionResult && (!elementHasAlreadyBeenAdded || el.__x_transition)) {
       const clone = document.importNode(el.content, true);
       el.parentElement.insertBefore(clone, el.nextElementSibling);
       transitionIn(el.nextElementSibling, () => {}, component, initialUpdate);
@@ -733,6 +797,10 @@
       passive: modifiers.includes('passive')
     };
 
+    if (modifiers.includes('camel')) {
+      event = camelCase(event);
+    }
+
     if (modifiers.includes('away')) {
       let handler = e => {
         // Don't do anything if the click came from the element or within it.
@@ -1375,6 +1443,10 @@
         // Alpine's got it's grubby little paws all over everything.
         initReturnedCallback.call(this.$data);
       }
+
+      componentForClone || setTimeout(() => {
+        Alpine.onComponentInitializeds.forEach(callback => callback(this));
+      }, 0);
     }
 
     getUnobservedData() {
@@ -1548,13 +1620,13 @@
       }) => {
         switch (type) {
           case 'model':
-            handleAttributeBindingDirective(this, el, 'value', expression, extraVars, type);
+            handleAttributeBindingDirective(this, el, 'value', expression, extraVars, type, modifiers);
             break;
 
           case 'bind':
             // 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, type);
+            handleAttributeBindingDirective(this, el, value, expression, extraVars, type, modifiers);
             break;
 
           case 'text':
@@ -1574,7 +1646,7 @@
           case 'if':
             // If this element also has x-for on it, don't process x-if.
             // We will let the "x-for" directive handle the "if"ing.
-            if (attrs.filter(i => i.type === 'for').length > 0) return;
+            if (attrs.some(i => i.type === 'for')) return;
             var output = this.evaluateReturnExpression(el, expression, extraVars);
             handleIfDirective(this, el, output, initialUpdate, extraVars);
             break;
@@ -1680,9 +1752,10 @@
   }
 
   const Alpine = {
-    version: "2.4.1",
+    version: "2.5.0",
     pauseMutationObserver: false,
     magicProperties: {},
+    onComponentInitializeds: [],
     start: async function start() {
       if (!isTesting()) {
         await domReady();
@@ -1761,6 +1834,9 @@
     },
     addMagicProperty: function addMagicProperty(name, callback) {
       this.magicProperties[name] = callback;
+    },
+    onComponentInitialized: function onComponentInitialized(callback) {
+      this.onComponentInitializeds.push(callback);
     }
   };
 

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 460 - 267
package-lock.json


+ 4 - 4
package.json

@@ -1,7 +1,7 @@
 {
     "main": "dist/alpine.js",
     "name": "alpinejs",
-    "version": "2.4.1",
+    "version": "2.5.0",
     "repository": {
         "type": "git",
         "url": "git://github.com/alpinejs/alpine.git"
@@ -15,8 +15,8 @@
     "author": "Caleb Porzio",
     "license": "MIT",
     "devDependencies": {
-        "@babel/core": "^7.10.2",
-        "@babel/preset-env": "^7.10.2",
+        "@babel/core": "^7.10.5",
+        "@babel/preset-env": "^7.10.4",
         "@rollup/plugin-commonjs": "^11.1.0",
         "@rollup/plugin-multi-entry": "^3.0.1",
         "@rollup/plugin-replace": "^2.3.3",
@@ -33,7 +33,7 @@
         "jest": "^25.5.4",
         "jsdom-simulant": "^1.1.2",
         "observable-membrane": "^0.26.1",
-        "proxy-polyfill": "^0.3.1",
+        "proxy-polyfill": "^0.3.2",
         "rollup": "^1.32.1",
         "rollup-plugin-babel": "^4.4.0",
         "rollup-plugin-filesize": "^6.2.1",

+ 7 - 3
src/component.js

@@ -86,6 +86,10 @@ export default class Component {
             // Alpine's got it's grubby little paws all over everything.
             initReturnedCallback.call(this.$data)
         }
+
+        componentForClone || setTimeout(() => {
+            Alpine.onComponentInitializeds.forEach(callback => callback(this))
+        }, 0)
     }
 
     getUnobservedData() {
@@ -264,14 +268,14 @@ export default class Component {
         attrs.forEach(({ type, value, modifiers, expression }) => {
             switch (type) {
                 case 'model':
-                    handleAttributeBindingDirective(this, el, 'value', expression, extraVars, type)
+                    handleAttributeBindingDirective(this, el, 'value', expression, extraVars, type, modifiers)
                     break;
 
                 case 'bind':
                     // 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, type)
+                    handleAttributeBindingDirective(this, el, value, expression, extraVars, type, modifiers)
                     break;
 
                 case 'text':
@@ -293,7 +297,7 @@ export default class Component {
                 case 'if':
                     // If this element also has x-for on it, don't process x-if.
                     // We will let the "x-for" directive handle the "if"ing.
-                    if (attrs.filter(i => i.type === 'for').length > 0) return
+                    if (attrs.some(i => i.type === 'for')) return
 
                     var output = this.evaluateReturnExpression(el, expression, extraVars)
 

+ 4 - 2
src/directives/bind.js

@@ -1,6 +1,6 @@
-import { arrayUnique, isBooleanAttr, convertClassStringToArray } from '../utils'
+import { arrayUnique, isBooleanAttr, convertClassStringToArray, camelCase } from '../utils'
 
-export function handleAttributeBindingDirective(component, el, attrName, expression, extraVars, attrType) {
+export function handleAttributeBindingDirective(component, el, attrName, expression, extraVars, attrType, modifiers) {
     var value = component.evaluateReturnExpression(el, expression, extraVars)
 
     if (attrName === 'value') {
@@ -63,6 +63,8 @@ export function handleAttributeBindingDirective(component, el, attrName, express
             el.setAttribute('class', arrayUnique(originalClasses.concat(newClasses)).join(' '))
         }
     } else {
+        attrName = modifiers.includes('camel') ? camelCase(attrName) : attrName
+
         // If an attribute's bound value is null, undefined or false, remove the attribute
         if ([null, undefined, false].includes(value)) {
             el.removeAttribute(attrName)

+ 1 - 1
src/directives/if.js

@@ -5,7 +5,7 @@ export function handleIfDirective(component, el, expressionResult, initialUpdate
 
     const elementHasAlreadyBeenAdded = el.nextElementSibling && el.nextElementSibling.__x_inserted_me === true
 
-    if (expressionResult && ! elementHasAlreadyBeenAdded) {
+    if (expressionResult && (! elementHasAlreadyBeenAdded || el.__x_transition)) {
         const clone = document.importNode(el.content, true);
 
         el.parentElement.insertBefore(clone, el.nextElementSibling)

+ 6 - 1
src/directives/on.js

@@ -1,9 +1,14 @@
-import { kebabCase, debounce, isNumeric } from '../utils'
+import { kebabCase, camelCase, debounce, isNumeric } from '../utils'
 
 export function registerListener(component, el, event, modifiers, expression, extraVars = {}) {
     const options = {
         passive: modifiers.includes('passive'),
     };
+
+    if (modifiers.includes('camel')) {
+        event = camelCase(event);
+    }
+
     if (modifiers.includes('away')) {
         let handler = e => {
             // Don't do anything if the click came from the element or within it.

+ 6 - 4
src/directives/show.js

@@ -24,12 +24,14 @@ export function handleShowDirective(component, el, value, modifiers, initialUpda
 
     const handle = (resolve) => {
         if (value) {
-            transitionIn(el,() => {
-                show()
-            }, component)
+            if(el.style.display === 'none' || el.__x_transition) {
+                transitionIn(el, () => {
+                    show()
+                }, component)
+            }
             resolve(() => {})
         } else {
-            if (el.style.display !== 'none' ) {
+            if (el.style.display !== 'none') {
                 transitionOut(el, () => {
                     resolve(() => {
                         hide()

+ 6 - 0
src/index.js

@@ -8,6 +8,8 @@ const Alpine = {
 
     magicProperties: {},
 
+    onComponentInitializeds: [],
+
     start: async function () {
         if (! isTesting()) {
             await domReady()
@@ -103,6 +105,10 @@ const Alpine = {
 
     addMagicProperty: function (name, callback) {
         this.magicProperties[name] = callback
+    },
+
+    onComponentInitialized: function (callback) {
+        this.onComponentInitializeds.push(callback)
     }
 }
 

+ 94 - 29
src/utils.js

@@ -32,6 +32,10 @@ export function kebabCase(subject) {
     return subject.replace(/([a-z])([A-Z])/g, '$1-$2').replace(/[_\s]/, '-').toLowerCase()
 }
 
+export function camelCase(subject) {
+    return subject.toLowerCase().replace(/[^a-zA-Z0-9]+(.)/g, (match, char) => char.toUpperCase())
+}
+
 export function walk(el, callback) {
     if (callback(el) === false) return
 
@@ -69,7 +73,7 @@ export function saferEval(expression, dataContext, additionalHelperVariables = {
 
 export function saferEvalNoReturn(expression, dataContext, additionalHelperVariables = {}) {
     if (typeof expression === 'function') {
-        return expression.call(dataContext)
+        return expression.call(dataContext, additionalHelperVariables['$event'])
     }
 
     // For the cases when users pass only a function reference to the caller: `x-on:click="foo"`
@@ -110,11 +114,19 @@ export function getXAttrs(el, component, type) {
         directives = directives.concat(Object.entries(spreadObject).map(([name, value]) => parseHtmlAttribute({ name, value })))
     }
 
-    return directives.filter(i => {
-        // If no type is passed in for filtering, bypass filter
-        if (! type) return true
+    if (type) return directives.filter(i => i.type === type)
+
+    return sortDirectives(directives)
+}
+
+function sortDirectives(directives) {
+    let directiveOrder = ['bind', 'model', 'show', 'catch-all']
+
+    return directives.sort((a, b) => {
+        let typeA = directiveOrder.indexOf(a.type) === -1 ? 'catch-all' : a.type
+        let typeB = directiveOrder.indexOf(b.type) === -1 ? 'catch-all' : b.type
 
-        return i.type === type
+        return directiveOrder.indexOf(typeA) - directiveOrder.indexOf(typeB)
     })
 }
 
@@ -161,9 +173,19 @@ export function convertClassStringToArray(classList, filterFn = Boolean) {
     return classList.split(' ').filter(filterFn)
 }
 
+const TRANSITION_TYPE_IN = 'in'
+const TRANSITION_TYPE_OUT = 'out'
+
 export function transitionIn(el, show, component, forceSkip = false) {
+    // We don't want to transition on the initial page load.
     if (forceSkip) return show()
 
+    if (el.__x_transition && el.__x_transition.type === TRANSITION_TYPE_IN) {
+        // there is already a similar transition going on, this was probably triggered by
+        // a change in a different property, let's just leave the previous one doing its job
+        return
+    }
+
     const attrs = getXAttrs(el, component, 'transition')
     const showAttr = getXAttrs(el, component, 'show')[0]
 
@@ -182,7 +204,7 @@ export function transitionIn(el, show, component, forceSkip = false) {
 
         transitionHelperIn(el, modifiers, show)
     // Otherwise, we can assume x-transition:enter.
-    } else if (attrs.filter(attr => ['enter', 'enter-start', 'enter-end'].includes(attr.value)).length > 0) {
+    } else if (attrs.some(attr => ['enter', 'enter-start', 'enter-end'].includes(attr.value))) {
         transitionClassesIn(el, component, attrs, show)
     } else {
     // If neither, just show that damn thing.
@@ -191,9 +213,15 @@ export function transitionIn(el, show, component, forceSkip = false) {
 }
 
 export function transitionOut(el, hide, component, forceSkip = false) {
-     // We don't want to transition on the initial page load.
+    // We don't want to transition on the initial page load.
     if (forceSkip) return hide()
 
+    if (el.__x_transition && el.__x_transition.type === TRANSITION_TYPE_OUT) {
+        // there is already a similar transition going on, this was probably triggered by
+        // a change in a different property, let's just leave the previous one doing its job
+        return
+    }
+
     const attrs = getXAttrs(el, component, 'transition')
     const showAttr = getXAttrs(el, component, 'show')[0]
 
@@ -208,7 +236,7 @@ export function transitionOut(el, hide, component, forceSkip = false) {
             ? modifiers.filter((i, index) => index > modifiers.indexOf('out')) : modifiers
 
         transitionHelperOut(el, modifiers, settingBothSidesOfTransition, hide)
-    } else if (attrs.filter(attr => ['leave', 'leave-start', 'leave-end'].includes(attr.value)).length > 0) {
+    } else if (attrs.some(attr => ['leave', 'leave-start', 'leave-end'].includes(attr.value))) {
         transitionClassesOut(el, component, attrs, hide)
     } else {
         hide()
@@ -230,7 +258,7 @@ export function transitionHelperIn(el, modifiers, showCallback) {
         },
     }
 
-    transitionHelper(el, modifiers, showCallback, () => {}, styleValues)
+    transitionHelper(el, modifiers, showCallback, () => {}, styleValues, TRANSITION_TYPE_IN)
 }
 
 export function transitionHelperOut(el, modifiers, settingBothSidesOfTransition, hideCallback) {
@@ -254,7 +282,7 @@ export function transitionHelperOut(el, modifiers, settingBothSidesOfTransition,
         },
     }
 
-    transitionHelper(el, modifiers, () => {}, hideCallback, styleValues)
+    transitionHelper(el, modifiers, () => {}, hideCallback, styleValues, TRANSITION_TYPE_OUT)
 }
 
 function modifierValue(modifiers, key, fallback) {
@@ -289,7 +317,13 @@ function modifierValue(modifiers, key, fallback) {
     return rawValue
 }
 
-export function transitionHelper(el, modifiers, hook1, hook2, styleValues) {
+export function transitionHelper(el, modifiers, hook1, hook2, styleValues, type) {
+    // clear the previous transition if exists to avoid caching the wrong styles
+    if (el.__x_transition) {
+        cancelAnimationFrame(el.__x_transition.nextFrame)
+        el.__x_transition.callback && el.__x_transition.callback()
+    }
+
     // If the user set these style values, we'll put them back when we're done with them.
     const opacityCache = el.style.opacity
     const transformCache = el.style.transform
@@ -334,7 +368,7 @@ export function transitionHelper(el, modifiers, hook1, hook2, styleValues) {
         },
     }
 
-    transition(el, stages)
+    transition(el, stages, type)
 }
 
 export function transitionClassesIn(el, component, directives, showCallback) {
@@ -348,7 +382,7 @@ export function transitionClassesIn(el, component, directives, showCallback) {
     const enterStart = convertClassStringToArray(ensureStringExpression((directives.find(i => i.value === 'enter-start') || { expression: '' }).expression))
     const enterEnd = convertClassStringToArray(ensureStringExpression((directives.find(i => i.value === 'enter-end') || { expression: '' }).expression))
 
-    transitionClasses(el, enter, enterStart, enterEnd, showCallback, () => {})
+    transitionClasses(el, enter, enterStart, enterEnd, showCallback, () => {}, TRANSITION_TYPE_IN)
 }
 
 export function transitionClassesOut(el, component, directives, hideCallback) {
@@ -356,10 +390,16 @@ export function transitionClassesOut(el, component, directives, hideCallback) {
     const leaveStart = convertClassStringToArray((directives.find(i => i.value === 'leave-start') || { expression: '' }).expression)
     const leaveEnd = convertClassStringToArray((directives.find(i => i.value === 'leave-end') || { expression: '' }).expression)
 
-    transitionClasses(el, leave, leaveStart, leaveEnd, () => {}, hideCallback)
+    transitionClasses(el, leave, leaveStart, leaveEnd, () => {}, hideCallback, TRANSITION_TYPE_OUT)
 }
 
-export function transitionClasses(el, classesDuring, classesStart, classesEnd, hook1, hook2) {
+export function transitionClasses(el, classesDuring, classesStart, classesEnd, hook1, hook2, type) {
+    // clear the previous transition if exists to avoid caching the wrong classes
+    if (el.__x_transition) {
+        cancelAnimationFrame(el.__x_transition.nextFrame)
+        el.__x_transition.callback && el.__x_transition.callback()
+    }
+
     const originalClasses = el.__x_original_classes || []
 
     const stages = {
@@ -386,14 +426,35 @@ export function transitionClasses(el, classesDuring, classesStart, classesEnd, h
         },
     }
 
-    transition(el, stages)
+    transition(el, stages, type)
 }
 
-export function transition(el, stages) {
+export function transition(el, stages, type) {
+    el.__x_transition = {
+        // Set transition type so we can avoid clearing transition if the direction is the same
+       type: type,
+        // create a callback for the last stages of the transition so we can call it
+        // from different point and early terminate it. Once will ensure that function
+        // is only called one time.
+        callback: once(() => {
+            stages.hide()
+
+            // Adding an "isConnected" check, in case the callback
+            // removed the element from the DOM.
+            if (el.isConnected) {
+                stages.cleanup()
+            }
+
+            delete el.__x_transition
+        }),
+        // This store the next animation frame so we can cancel it
+        nextFrame: null
+    }
+
     stages.start()
     stages.during()
 
-    requestAnimationFrame(() => {
+    el.__x_transition.nextFrame =requestAnimationFrame(() => {
         // Note: Safari's transitionDuration property will list out comma separated transition durations
         // for every single transition property. Let's grab the first one and call it a day.
         let duration = Number(getComputedStyle(el).transitionDuration.replace(/,.*/, '').replace('s', '')) * 1000
@@ -404,19 +465,10 @@ export function transition(el, stages) {
 
         stages.show()
 
-        requestAnimationFrame(() => {
+        el.__x_transition.nextFrame =requestAnimationFrame(() => {
             stages.end()
 
-            // Assign current transition to el in case we need to force it.
-            setTimeout(() => {
-                stages.hide()
-
-                // Adding an "isConnected" check, in case the callback
-                // removed the element from the DOM.
-                if (el.isConnected) {
-                    stages.cleanup()
-                }
-            }, duration)
+            setTimeout(el.__x_transition.callback, duration)
         })
     });
 }
@@ -424,3 +476,16 @@ export function transition(el, stages) {
 export function isNumeric(subject){
     return ! isNaN(subject)
 }
+
+// Thanks @vuejs
+// https://github.com/vuejs/vue/blob/4de4649d9637262a9b007720b59f80ac72a5620c/src/shared/util.js
+export function once(callback) {
+    let called = false
+
+    return function () {
+        if (! called) {
+            called = true
+            callback.apply(this, arguments)
+        }
+    }
+}

+ 12 - 0
test/bind.spec.js

@@ -483,3 +483,15 @@ test('extra whitespace in class binding string syntax is ignored', async () => {
     expect(document.querySelector('span').classList.contains('foo')).toBeTruthy()
     expect(document.querySelector('span').classList.contains('bar')).toBeTruthy()
 })
+
+test('.camel modifier correctly sets name of attribute', async () => {
+    document.body.innerHTML = `
+        <div x-data>
+            <svg x-bind:view-box.camel="'0 0 42 42'"></svg>
+        </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelector('svg').getAttribute('viewBox')).toEqual('0 0 42 42')
+})

+ 392 - 21
test/model.spec.js

@@ -2,7 +2,7 @@ import Alpine from 'alpinejs'
 import { wait, fireEvent } from '@testing-library/dom'
 
 global.MutationObserver = class {
-    observe() {}
+    observe() { }
 }
 
 test('x-model has value binding when initialized', async () => {
@@ -26,7 +26,7 @@ test('x-model updates value when updated via input event', async () => {
 
     Alpine.start()
 
-    fireEvent.input(document.querySelector('input'), { target: { value: 'baz' }})
+    fireEvent.input(document.querySelector('input'), { target: { value: 'baz' } })
 
     await wait(() => { expect(document.querySelector('input').value).toEqual('baz') })
 })
@@ -56,7 +56,7 @@ test('x-model casts value to number if number modifier is present', async () =>
 
     Alpine.start()
 
-    fireEvent.input(document.querySelector('input'), { target: { value: '123' }})
+    fireEvent.input(document.querySelector('input'), { target: { value: '123' } })
 
     await wait(() => { expect(document.querySelector('[x-data]').__x.$data.foo).toEqual(123) })
 })
@@ -71,27 +71,27 @@ test('x-model with number modifier returns: null if empty, original value if cas
 
     Alpine.start()
 
-    fireEvent.input(document.querySelectorAll('input')[0], { target: { value: '' }})
+    fireEvent.input(document.querySelectorAll('input')[0], { target: { value: '' } })
 
     await wait(() => { expect(document.querySelector('[x-data]').__x.$data.foo).toEqual(null) })
 
-    fireEvent.input(document.querySelectorAll('input')[0], { target: { value: '-' }})
+    fireEvent.input(document.querySelectorAll('input')[0], { target: { value: '-' } })
 
     await wait(() => { expect(document.querySelector('[x-data]').__x.$data.foo).toEqual(null) })
 
-    fireEvent.input(document.querySelectorAll('input')[0], { target: { value: '-123' }})
+    fireEvent.input(document.querySelectorAll('input')[0], { target: { value: '-123' } })
 
     await wait(() => { expect(document.querySelector('[x-data]').__x.$data.foo).toEqual(-123) })
 
-    fireEvent.input(document.querySelectorAll('input')[1], { target: { value: '' }})
+    fireEvent.input(document.querySelectorAll('input')[1], { target: { value: '' } })
 
     await wait(() => { expect(document.querySelector('[x-data]').__x.$data.bar).toEqual(null) })
 
-    fireEvent.input(document.querySelectorAll('input')[1], { target: { value: '-' }})
+    fireEvent.input(document.querySelectorAll('input')[1], { target: { value: '-' } })
 
     await wait(() => { expect(document.querySelector('[x-data]').__x.$data.bar).toEqual('-') })
 
-    fireEvent.input(document.querySelectorAll('input')[1], { target: { value: '-123' }})
+    fireEvent.input(document.querySelectorAll('input')[1], { target: { value: '-123' } })
 
     await wait(() => { expect(document.querySelector('[x-data]').__x.$data.bar).toEqual(-123) })
 })
@@ -107,7 +107,7 @@ test('x-model trims value if trim modifier is present', async () => {
 
     Alpine.start()
 
-    fireEvent.input(document.querySelector('input'), { target: { value: 'bar   ' }})
+    fireEvent.input(document.querySelector('input'), { target: { value: 'bar   ' } })
 
     await wait(() => { expect(document.querySelector('span').innerText).toEqual('bar') })
 })
@@ -121,7 +121,7 @@ test('x-model updates value when updated via changed event when lazy modifier is
 
     Alpine.start()
 
-    fireEvent.change(document.querySelector('input'), { target: { value: 'baz' }})
+    fireEvent.change(document.querySelector('input'), { target: { value: 'baz' } })
 
     await wait(() => { expect(document.querySelector('input').value).toEqual('baz') })
 })
@@ -140,7 +140,7 @@ test('x-model binds checkbox value', async () => {
     expect(document.querySelector('input').checked).toEqual(true)
     expect(document.querySelector('span').getAttribute('bar')).toEqual("true")
 
-    fireEvent.change(document.querySelector('input'), { target: { checked: false }})
+    fireEvent.change(document.querySelector('input'), { target: { checked: false } })
 
     await wait(() => { expect(document.querySelector('span').getAttribute('bar')).toEqual("false") })
 })
@@ -161,7 +161,7 @@ test('x-model binds checkbox value to array', async () => {
     expect(document.querySelectorAll('input')[1].checked).toEqual(false)
     expect(document.querySelector('span').getAttribute('bar')).toEqual("bar")
 
-    fireEvent.change(document.querySelectorAll('input')['1'], { target: { checked: true }})
+    fireEvent.change(document.querySelectorAll('input')['1'], { target: { checked: true } })
 
     await wait(() => {
         expect(document.querySelectorAll('input')[0].checked).toEqual(true)
@@ -192,16 +192,16 @@ test('x-model checkbox array binding supports .number modifier', async () => {
     expect(document.querySelectorAll('input[type=checkbox]')[2].checked).toEqual(false)
     expect(document.querySelector('span').getAttribute('bar')).toEqual("[2]")
 
-    fireEvent.change(document.querySelectorAll('input[type=checkbox]')[2], { target: { checked: true }})
+    fireEvent.change(document.querySelectorAll('input[type=checkbox]')[2], { target: { checked: true } })
 
     await wait(() => { expect(document.querySelector('span').getAttribute('bar')).toEqual("[2,3]") })
 
-    fireEvent.change(document.querySelectorAll('input[type=checkbox]')[0], { target: { checked: true }})
+    fireEvent.change(document.querySelectorAll('input[type=checkbox]')[0], { target: { checked: true } })
 
     await wait(() => { expect(document.querySelector('span').getAttribute('bar')).toEqual("[2,3,1]") })
 
-    fireEvent.change(document.querySelectorAll('input[type=checkbox]')[0], { target: { checked: false }})
-    fireEvent.change(document.querySelectorAll('input[type=checkbox]')[1], { target: { checked: false }})
+    fireEvent.change(document.querySelectorAll('input[type=checkbox]')[0], { target: { checked: false } })
+    fireEvent.change(document.querySelectorAll('input[type=checkbox]')[1], { target: { checked: false } })
     await wait(() => { expect(document.querySelector('span').getAttribute('bar')).toEqual("[3]") })
 })
 
@@ -221,7 +221,7 @@ test('x-model binds radio value', async () => {
     expect(document.querySelectorAll('input')[1].checked).toEqual(false)
     expect(document.querySelector('span').getAttribute('bar')).toEqual('bar')
 
-    fireEvent.change(document.querySelectorAll('input')[1], { target: { checked: true }})
+    fireEvent.change(document.querySelectorAll('input')[1], { target: { checked: true } })
 
     await wait(() => {
         expect(document.querySelectorAll('input')[0].checked).toEqual(false)
@@ -250,7 +250,7 @@ test('x-model binds select dropdown', async () => {
     expect(document.querySelectorAll('option')[2].selected).toEqual(false)
     expect(document.querySelector('span').innerText).toEqual('bar')
 
-    fireEvent.change(document.querySelector('select'), { target: { value: 'baz' }});
+    fireEvent.change(document.querySelector('select'), { target: { value: 'baz' } });
 
     await wait(() => {
         expect(document.querySelectorAll('option')[0].selected).toEqual(false)
@@ -304,7 +304,7 @@ test('x-model binds nested keys', async () => {
     expect(document.querySelector('input').value).toEqual('foo')
     expect(document.querySelector('span').innerText).toEqual('foo')
 
-    fireEvent.input(document.querySelector('input'), { target: { value: 'bar' }})
+    fireEvent.input(document.querySelector('input'), { target: { value: 'bar' } })
 
     await wait(() => {
         expect(document.querySelector('input').value).toEqual('bar')
@@ -325,7 +325,7 @@ test('x-model undefined nested model key defaults to empty string', async () =>
     expect(document.querySelector('input').value).toEqual('')
     expect(document.querySelector('span').innerText).toEqual('')
 
-    fireEvent.input(document.querySelector('input'), { target: { value: 'bar' }})
+    fireEvent.input(document.querySelector('input'), { target: { value: 'bar' } })
 
     await wait(() => {
         expect(document.querySelector('input').value).toEqual('bar')
@@ -352,3 +352,374 @@ test('x-model can listen for custom input event dispatches', async () => {
         expect(document.querySelector('span').innerText).toEqual('baz')
     })
 })
+
+// <input type="color">
+test('x-model bind color input', async () => {
+    document.body.innerHTML = `
+    <div x-data="{ key: '#ff0000' }">
+        <input type="color" x-model="key">
+        <span x-text="key"></span>
+    </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelector('input').value).toEqual('#ff0000')
+    expect(document.querySelector('span').innerText).toEqual('#ff0000')
+
+    fireEvent.input(document.querySelector('input'), { target: { value: '#00ff00' } });
+
+    await wait(() => {
+        expect(document.querySelector('input').value).toEqual('#00ff00')
+        expect(document.querySelector('span').innerText).toEqual('#00ff00')
+    })
+
+})
+
+// <input type="button">
+test('x-model bind button input', async () => {
+    document.body.innerHTML = `
+    <div x-data="{ key: 'foo' }">
+        <input type="button" x-model="key">
+        <span x-text="key"></span>
+    </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelector('input').value).toEqual('foo')
+    expect(document.querySelector('span').innerText).toEqual('foo')
+
+    fireEvent.input(document.querySelector('input'), { target: { value: 'bar' } });
+
+    await wait(() => {
+        expect(document.querySelector('input').value).toEqual('bar')
+        expect(document.querySelector('span').innerText).toEqual('bar')
+    })
+})
+
+// <input type="date">
+test('x-model bind date input', async () => {
+    document.body.innerHTML = `
+    <div x-data="{ key: '2020-07-10' }">
+      <input type="date" x-model="key" />
+      <span x-text="key"></span>
+    </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelector('input').value).toEqual('2020-07-10')
+    expect(document.querySelector('span').innerText).toEqual('2020-07-10')
+
+    fireEvent.input(document.querySelector('input'), { target: { value: '2021-01-01' } });
+
+    await wait(() => {
+        expect(document.querySelector('input').value).toEqual('2021-01-01')
+        expect(document.querySelector('span').innerText).toEqual('2021-01-01')
+    })
+})
+
+// <input type="datetime-local">
+test('x-model bind datetime-local input', async () => {
+    document.body.innerHTML = `
+    <div x-data="{ key: '2020-01-01T20:00' }">
+      <input type="datetime-local" x-model="key" />
+      <span x-text="key"></span>
+    </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelector('input').value).toEqual('2020-01-01T20:00')
+    expect(document.querySelector('span').innerText).toEqual('2020-01-01T20:00')
+
+    fireEvent.input(document.querySelector('input'), { target: { value: '2021-02-02T20:00' } });
+
+    await wait(() => {
+        expect(document.querySelector('input').value).toEqual('2021-02-02T20:00')
+        expect(document.querySelector('span').innerText).toEqual('2021-02-02T20:00')
+    })
+})
+
+// <input type="email">
+test('x-model bind email input', async () => {
+})
+
+// <input type="month">
+test('x-model bind month input', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ key: '2020-04' }">
+        <input type="month" x-model="key" />
+        <span x-text="key"></span>
+        </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelector('input').value).toEqual('2020-04')
+    expect(document.querySelector('span').innerText).toEqual('2020-04')
+
+    fireEvent.input(document.querySelector('input'), { target: { value: '2021-05' } });
+
+    await wait(() => {
+        expect(document.querySelector('input').value).toEqual('2021-05')
+        expect(document.querySelector('span').innerText).toEqual('2021-05')
+    })
+})
+
+
+// <input type="number">
+test('x-model bind number input', async () => {
+    document.body.innerHTML = `
+    <div x-data="{ key: '11' }">
+        <input type="number" x-model="key" />
+        <span x-text="key"></span>
+    </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelector('input').value).toEqual('11')
+    expect(document.querySelector('span').innerText).toEqual('11')
+
+    fireEvent.input(document.querySelector('input'), { target: { value: '2021' } });
+
+    await wait(() => {
+        expect(document.querySelector('input').value).toEqual('2021')
+        expect(document.querySelector('span').innerText).toEqual('2021')
+    })
+})
+
+// <input type="password">
+test('x-model bind password input', async () => {
+    document.body.innerHTML = `
+    <div x-data="{ key: 'SecretKey' }">
+        <input type="password" x-model="key" />
+        <span x-text="key"></span>
+    </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelector('input').value).toEqual('SecretKey')
+    expect(document.querySelector('span').innerText).toEqual('SecretKey')
+
+    fireEvent.input(document.querySelector('input'), { target: { value: 'NewSecretKey' } });
+
+    await wait(() => {
+        expect(document.querySelector('input').value).toEqual('NewSecretKey')
+        expect(document.querySelector('span').innerText).toEqual('NewSecretKey')
+    })
+})
+
+// <input type="range">
+test('x-model bind range input', async () => {
+    document.body.innerHTML = `
+    <div x-data="{ key: '10' }">
+        <input type="range" x-model="key" />
+        <span x-text="key"></span>
+    </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelector('input').value).toEqual('10')
+    expect(document.querySelector('span').innerText).toEqual('10')
+
+    fireEvent.input(document.querySelector('input'), { target: { value: '20' } });
+
+    await wait(() => {
+        expect(document.querySelector('input').value).toEqual('20')
+        expect(document.querySelector('span').innerText).toEqual('20')
+    })
+})
+
+// <input type="search">
+test('x-model bind search input', async () => {
+    document.body.innerHTML = `
+    <div x-data="{ key: '' }">
+        <input type="search" x-model="key" />
+        <span x-text="key"></span>
+    </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelector('input').value).toEqual('')
+    expect(document.querySelector('span').innerText).toEqual('')
+
+    const newValue = 'Frontend Frameworks';
+    fireEvent.input(document.querySelector('input'), { target: { value: newValue } });
+
+    await wait(() => {
+        expect(document.querySelector('input').value).toEqual(newValue)
+        expect(document.querySelector('span').innerText).toEqual(newValue)
+    })
+})
+
+// <input type="tel">
+test('x-model bind tel input', async () => {
+    document.body.innerHTML = `
+    <div x-data="{ key: '+12345678901' }">
+        <input type="tel" x-model="key" />
+        <span x-text="key"></span>
+    </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelector('input').value).toEqual('+12345678901')
+    expect(document.querySelector('span').innerText).toEqual('+12345678901')
+
+    const newValue = '+1239874560';
+    fireEvent.input(document.querySelector('input'), { target: { value: newValue } });
+
+    await wait(() => {
+        expect(document.querySelector('input').value).toEqual(newValue)
+        expect(document.querySelector('span').innerText).toEqual(newValue)
+    })
+})
+
+// <input type="tel">
+test('x-model bind tel input', async () => {
+    document.body.innerHTML = `
+    <div x-data="{ key: '+12345678901' }">
+        <input type="tel" x-model="key" />
+        <span x-text="key"></span>
+    </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelector('input').value).toEqual('+12345678901')
+    expect(document.querySelector('span').innerText).toEqual('+12345678901')
+
+    const newValue = '+1239874560';
+    fireEvent.input(document.querySelector('input'), { target: { value: newValue } });
+
+    await wait(() => {
+        expect(document.querySelector('input').value).toEqual(newValue)
+        expect(document.querySelector('span').innerText).toEqual(newValue)
+    })
+})
+
+// <input type="tel">
+test('x-model bind time input', async () => {
+    document.body.innerHTML = `
+    <div x-data="{ key: '22:00' }">
+        <input type="time" x-model="key" />
+        <span x-text="key"></span>
+    </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelector('input').value).toEqual('22:00')
+    expect(document.querySelector('span').innerText).toEqual('22:00')
+
+    const newValue = '23:00';
+    fireEvent.input(document.querySelector('input'), { target: { value: newValue } });
+
+    await wait(() => {
+        expect(document.querySelector('input').value).toEqual(newValue)
+        expect(document.querySelector('span').innerText).toEqual(newValue)
+    })
+})
+
+// <input type="time">
+test('x-model bind time input', async () => {
+    document.body.innerHTML = `
+    <div x-data="{ key: '22:00' }">
+        <input type="time" x-model="key" />
+        <span x-text="key"></span>
+    </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelector('input').value).toEqual('22:00')
+    expect(document.querySelector('span').innerText).toEqual('22:00')
+
+    const newValue = '23:00';
+    fireEvent.input(document.querySelector('input'), { target: { value: newValue } });
+
+    await wait(() => {
+        expect(document.querySelector('input').value).toEqual(newValue)
+        expect(document.querySelector('span').innerText).toEqual(newValue)
+    })
+})
+
+// <input type="week">
+test('x-model bind week input', async () => {
+    document.body.innerHTML = `
+    <div x-data="{ key: '2020-W20' }">
+        <input type="week" x-model="key" />
+        <span x-text="key"></span>
+    </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelector('input').value).toEqual('2020-W20')
+    expect(document.querySelector('span').innerText).toEqual('2020-W20')
+
+    const newValue = '2020-W30';
+    fireEvent.input(document.querySelector('input'), { target: { value: newValue } });
+
+    await wait(() => {
+        expect(document.querySelector('input').value).toEqual(newValue)
+        expect(document.querySelector('span').innerText).toEqual(newValue)
+    })
+})
+
+// <input type="url">
+test('x-model bind url input', async () => {
+    document.body.innerHTML = `
+    <div x-data="{ key: 'https://example.com' }">
+        <input type="url" x-model="key" />
+        <span x-text="key"></span>
+    </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelector('input').value).toEqual('https://example.com')
+    expect(document.querySelector('span').innerText).toEqual('https://example.com')
+
+    const newValue = 'https://alpine.io';
+    fireEvent.input(document.querySelector('input'), { target: { value: newValue } });
+
+    await wait(() => {
+        expect(document.querySelector('input').value).toEqual(newValue)
+        expect(document.querySelector('span').innerText).toEqual(newValue)
+    })
+})
+
+test('x-model sets value before x-on directive expression is processed', async () => {
+    window.selectValueA
+    window.selectValueB
+
+    document.body.innerHTML = `
+        <div x-data="{ a: 'foo', b: 'foo' }">
+            <select x-model="a" @change="window.selectValueA = a">
+                <option>foo</option>
+                <option>bar</option>
+            </select>
+            <select @change="window.selectValueB = b" x-model="b">
+                <option>foo</option>
+                <option>bar</option>
+            </select>
+        </div>
+    `
+
+    Alpine.start()
+
+    fireEvent.change(document.querySelectorAll('select')[0], { target: { value: 'bar' } });
+    fireEvent.change(document.querySelectorAll('select')[1], { target: { value: 'bar' } });
+
+    await wait(() => {
+        expect(window.selectValueA).toEqual('bar')
+        expect(window.selectValueB).toEqual('bar')
+    })
+})

+ 21 - 0
test/on.spec.js

@@ -519,3 +519,24 @@ test('autocomplete event does not trigger keydown with modifier callback', async
 
     await wait(() => { expect(document.querySelector('span').innerText).toEqual(1) })
 })
+
+test('.camel modifier correctly binds event listener', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ foo: 'bar' }" x-on:event-name.camel.window="foo = 'bob'">
+            <button x-on:click="$dispatch('eventName')"></button>
+            <p x-text="foo"></p>
+        </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelector('p').innerText).toEqual('bar')
+
+    document.querySelector('button').click();
+
+    await wait(() => {
+        expect(document.querySelector('p').innerText).toEqual('bob');
+    });
+})
+
+

+ 47 - 0
test/show.spec.js

@@ -126,3 +126,50 @@ test('x-show works with nested x-shows of different functions (hiding vs showing
         expect(document.querySelector('h1').getAttribute('style')).toEqual(null)
     })
 })
+
+// Regression in 2.4.0
+test('x-show with x-bind:style inside x-for works correctly', async () => {
+    document.body.innerHTML = `
+        <div x-data="{items: [{ cleared: false }, { cleared: false }]}">
+            <template x-for="(item, index) in items" :key="index">
+                <button x-show="! item.cleared"
+                    x-bind:style="'background: #999'"
+                    @click="item.cleared = true"
+                >
+                </button>
+            </template>
+        </div>
+    `
+    Alpine.start()
+
+    expect(document.querySelectorAll('button')[0].style.display).toEqual('')
+    expect(document.querySelectorAll('button')[1].style.display).toEqual('')
+
+    document.querySelectorAll('button')[0].click()
+
+    await wait(() => {
+        expect(document.querySelectorAll('button')[0].style.display).toEqual('none')
+        expect(document.querySelectorAll('button')[1].style.display).toEqual('')
+    })
+
+    document.querySelectorAll('button')[1].click()
+
+    await wait(() => {
+        expect(document.querySelectorAll('button')[0].style.display).toEqual('none')
+        expect(document.querySelectorAll('button')[1].style.display).toEqual('none')
+    })
+})
+
+test('x-show takes precedence over style bindings for display property', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ show: false }">
+            <span x-show="show" :style="'color: red;'"></span>
+            <span :style="'color: red;'" x-show="show"></span>
+        </div>
+    `
+
+    Alpine.start()
+
+    expect(document.querySelectorAll('span')[0].getAttribute('style')).toContain('display: none;')
+    expect(document.querySelectorAll('span')[1].getAttribute('style')).toContain('display: none;')
+})

+ 28 - 0
test/spread.spec.js

@@ -146,3 +146,31 @@ test('x-spread syntax supports x-transition', async () => {
         }, 10)
     )
 })
+
+
+test('x-spread event handlers defined as functions receive the event object as their first argument', async () => {    
+    window.data = function () {
+        return {
+            eventType: null, 
+            button: {
+                ['@click']($event){
+                    this.eventType = $event.type;
+                }
+            }
+        };
+    };
+
+    document.body.innerHTML = `
+        <div x-data="window.data()">
+            <button x-spread="button">click me<button>
+        </div>
+    `;
+
+    Alpine.start();
+
+    document.querySelector("button").click();
+
+    await wait(() => {
+        expect(document.querySelector("div").__x.$data.eventType).toEqual("click");
+    });
+});

+ 125 - 13
test/transition.spec.js

@@ -135,15 +135,12 @@ test('transition out', async () => {
     expect(document.querySelector('span').classList.contains('leave-end')).toEqual(true)
     expect(document.querySelector('span').getAttribute('style')).toEqual(null)
 
-    await new Promise((resolve) =>
-        setTimeout(() => {
-            expect(document.querySelector('span').classList.contains('leave')).toEqual(false)
-            expect(document.querySelector('span').classList.contains('leave-start')).toEqual(false)
-            expect(document.querySelector('span').classList.contains('leave-end')).toEqual(false)
-            expect(document.querySelector('span').getAttribute('style')).toEqual('display: none;')
-            resolve();
-        }, 10)
-    )
+    await timeout(10)
+
+    expect(document.querySelector('span').classList.contains('leave')).toEqual(false)
+    expect(document.querySelector('span').classList.contains('leave-start')).toEqual(false)
+    expect(document.querySelector('span').classList.contains('leave-end')).toEqual(false)
+    expect(document.querySelector('span').getAttribute('style')).toEqual('display: none;')
 })
 
 test('if only transition leave directives are present, don\'t transition in at all', async () => {
@@ -281,8 +278,8 @@ test('transition in not called when item is already visible', async () => {
     });
 
     document.body.innerHTML = `
-        <div x-data="{ show: true }">
-            <button x-on:click="show = true"></button>
+        <div x-data="{ show: true, foo: 'bar' }">
+            <button x-on:click="foo = 'bob'"></button>
 
             <span
                 x-show="show"
@@ -330,8 +327,8 @@ test('transition out not called when item is already hidden', async () => {
     });
 
     document.body.innerHTML = `
-        <div x-data="{ show: false }">
-            <button x-on:click="show = false"></button>
+        <div x-data="{ show: false, foo: 'bar' }">
+            <button x-on:click="foo = 'bob'"></button>
 
             <span
                 x-show="show"
@@ -620,3 +617,118 @@ test('x-transition supports css animation', async () => {
     )
     expect(document.querySelector('span').classList.contains('animation-leave')).toEqual(false)
 })
+
+test('x-transition do not overlap', async () => {
+    jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
+        setTimeout(callback, 0)
+    });
+
+    document.body.innerHTML = `
+        <div x-data="{ show: true }">
+            <button x-on:click="show = ! show"></button>
+
+            <span x-show.transition="show"></span>
+        </div>
+    `
+
+    Alpine.start()
+
+    // Initial state
+    expect(document.querySelector('span').style.display).toEqual("")
+    expect(document.querySelector('span').style.opacity).toEqual("")
+    expect(document.querySelector('span').style.transform).toEqual("")
+    expect(document.querySelector('span').style.transformOrigin).toEqual("")
+
+    // Trigger transition out
+    document.querySelector('button').click()
+
+    // Trigger transition in before the previous one has finished
+    await timeout(10)
+    document.querySelector('button').click()
+
+    // Check the element is still visible and style properties are correct
+    await timeout(200)
+    expect(document.querySelector('span').style.display).toEqual("")
+    expect(document.querySelector('span').style.opacity).toEqual("")
+    expect(document.querySelector('span').style.transform).toEqual("")
+    expect(document.querySelector('span').style.transformOrigin).toEqual("")
+
+    // Hide the element
+    document.querySelector('button').click()
+    await timeout(200)
+    expect(document.querySelector('span').style.display).toEqual("none")
+    expect(document.querySelector('span').style.opacity).toEqual("")
+    expect(document.querySelector('span').style.transform).toEqual("")
+    expect(document.querySelector('span').style.transformOrigin).toEqual("")
+
+    // Trigger transition in
+    document.querySelector('button').click()
+
+    // Trigger transition out before the previous one has finished
+    await timeout(10)
+    document.querySelector('button').click()
+
+    // Check the element is hidden and style properties are correct
+    await timeout(200)
+    expect(document.querySelector('span').style.display).toEqual("none")
+    expect(document.querySelector('span').style.opacity).toEqual("")
+    expect(document.querySelector('span').style.transform).toEqual("")
+    expect(document.querySelector('span').style.transformOrigin).toEqual("")
+})
+
+test('x-transition using classes do not overlap', async () => {
+    jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
+        setTimeout(callback, 0)
+    });
+    jest.spyOn(window, 'getComputedStyle').mockImplementation(el => {
+        return { transitionDuration: '.1s' }
+    });
+
+    document.body.innerHTML = `
+        <div x-data="{ show: true }">
+            <button x-on:click="show = ! show"></button>
+
+            <span x-show="show"
+                x-transition:enter="enter"
+                x-transition:leave="leave">
+            </span>
+        </div>
+    `
+
+    Alpine.start()
+
+    // Initial state
+    expect(document.querySelector('span').style.display).toEqual("")
+
+    const emptyClassList = document.querySelector('span').classList
+
+    // Trigger transition out
+    document.querySelector('button').click()
+
+    // Trigger transition in before the previous one has finished
+    await timeout(10)
+    document.querySelector('button').click()
+
+    // Check the element is still visible and class property is correct
+    await timeout(200)
+    expect(document.querySelector('span').style.display).toEqual("")
+    expect(document.querySelector('span').classList).toEqual(emptyClassList)
+
+    // Hide the element
+    document.querySelector('button').click()
+    await timeout(200)
+    expect(document.querySelector('span').style.display).toEqual("none")
+    expect(document.querySelector('span').classList).toEqual(emptyClassList)
+
+    // Trigger transition in
+    document.querySelector('button').click()
+
+    // Trigger transition out before the previous one has finished
+    await timeout(10)
+    document.querySelector('button').click()
+
+    // Check the element is hidden and class property is correct
+    await timeout(200)
+    expect(document.querySelector('span').style.display).toEqual("none")
+    expect(document.querySelector('span').classList).toEqual(emptyClassList)
+})

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно