Przeglądaj źródła

Merge pull request #2 from alpinejs/master

Update
Ryan Chandler 5 lat temu
rodzic
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.
 

Plik diff jest za duży
+ 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);
     }
   };
 

Plik diff jest za duży
+ 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)
+})

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików