Caleb Porzio 5 年之前
父節點
當前提交
e3440d6be7
共有 11 個文件被更改,包括 856 次插入394 次删除
  1. 214 103
      dist/minimal.js
  2. 1 1
      dist/mix-manifest.json
  3. 29 3
      index.html
  4. 186 96
      src/component.js
  5. 1 0
      src/index.js
  6. 9 4
      src/utils.js
  7. 97 0
      test/bind.spec.js
  8. 202 0
      test/model.spec.js
  9. 85 0
      test/on.spec.js
  10. 0 187
      test/test.spec.js
  11. 32 0
      test/text.spec.js

+ 214 - 103
dist/minimal.js

@@ -132,151 +132,249 @@ function () {
     this.el = el;
     this.data = Object(_utils__WEBPACK_IMPORTED_MODULE_0__["saferEval"])(this.el.getAttribute('x-data'), {});
     this.concernedData = [];
-    this.registerListeners();
-    this.updateAllBoundAttributes();
+    this.initialize();
   }
 
   _createClass(Component, [{
-    key: "registerListeners",
-    value: function registerListeners() {
+    key: "initialize",
+    value: function initialize() {
       var _this = this;
 
       Object(_utils__WEBPACK_IMPORTED_MODULE_0__["walk"])(this.el, function (el) {
-        Object(_utils__WEBPACK_IMPORTED_MODULE_0__["getXAttrs"])(el, 'on').forEach(function (_ref) {
+        Object(_utils__WEBPACK_IMPORTED_MODULE_0__["getXAttrs"])(el).forEach(function (_ref) {
           var type = _ref.type,
               value = _ref.value,
               modifiers = _ref.modifiers,
               expression = _ref.expression;
 
-          if (modifiers.includes('away')) {
-            // Listen for this event at the root level.
-            document.addEventListener(value, function (e) {
-              var _this$concernedData;
+          switch (type) {
+            case 'on':
+              var event = value;
 
-              // Don't do anything if the click came form the element or within it.
-              if (el.contains(e.target)) return; // Don't do anything if this element isn't currently visible.
+              _this.registerListener(el, event, modifiers, expression);
 
-              if (el.offsetWidth < 1 && el.offsetHeight < 1) return; // Now that we are sure the element is visible, AND the click
-              // is from outside it, let's run the expression.
+              break;
 
-              (_this$concernedData = _this.concernedData).push.apply(_this$concernedData, _toConsumableArray(_this.evaluateExpressionWithEvent(expression, e)));
+            case 'model':
+              // If the element we are binding to is a select, a radio, or checkbox
+              // we'll listen for the change event instead of the "input" event.
+              var event = el.tagName.toLowerCase() === 'select' || ['checkbox', 'radio'].includes(el.type) || modifiers.includes('lazy') ? 'change' : 'input';
+              var rightSideOfExpression = '';
 
-              _this.concernedData = _this.concernedData.filter(_utils__WEBPACK_IMPORTED_MODULE_0__["onlyUnique"]);
+              if (el.type === 'checkbox') {
+                // If the data we are binding to is an array, toggle it's value inside the array.
+                if (Array.isArray(_this.data[expression])) {
+                  rightSideOfExpression = "$event.target.checked ? ".concat(expression, ".concat([$event.target.value]) : [...").concat(expression, ".splice(0, ").concat(expression, ".indexOf($event.target.value)), ...").concat(expression, ".splice(").concat(expression, ".indexOf($event.target.value)+1)]");
+                } else {
+                  rightSideOfExpression = "$event.target.checked";
+                }
+              } else if (el.tagName.toLowerCase() === 'select' && el.multiple) {
+                rightSideOfExpression = modifiers.includes('number') ? 'Array.from($event.target.selectedOptions).map(option => { return parseFloat(option.value || option.text) })' : 'Array.from($event.target.selectedOptions).map(option => { return option.value || option.text })';
+              } else {
+                rightSideOfExpression = modifiers.includes('number') ? 'parseFloat($event.target.value)' : modifiers.includes('trim') ? '$event.target.value.trim()' : '$event.target.value';
+              }
 
-              _this.updateBoundAttributes();
-            });
-          } else {
-            el.addEventListener(value, function (e) {
-              var _this$concernedData2;
+              if (el.type === 'radio') {
+                // Radio buttons only work properly when they share a name attribute.
+                // People might assume we take care of that for them, because
+                // they already set a shared "x-model" attribute.
+                if (!el.hasAttribute('name')) el.setAttribute('name', expression);
+              }
 
-              (_this$concernedData2 = _this.concernedData).push.apply(_this$concernedData2, _toConsumableArray(_this.evaluateExpressionWithEvent(expression, e)));
+              _this.registerListener(el, event, modifiers, "".concat(expression, " = ").concat(rightSideOfExpression));
 
-              _this.concernedData = _this.concernedData.filter(_utils__WEBPACK_IMPORTED_MODULE_0__["onlyUnique"]);
+              var attrName = 'value';
 
-              _this.updateBoundAttributes();
-            });
+              var _this$evaluateReturnE = _this.evaluateReturnExpression(expression),
+                  output = _this$evaluateReturnE.output;
+
+              _this.updateAttributeValue(el, attrName, output);
+
+              break;
+
+            case 'bind':
+              var attrName = value;
+
+              var _this$evaluateReturnE2 = _this.evaluateReturnExpression(expression),
+                  output = _this$evaluateReturnE2.output;
+
+              _this.updateAttributeValue(el, attrName, output);
+
+              break;
+
+            case 'text':
+              var _this$evaluateReturnE3 = _this.evaluateReturnExpression(expression),
+                  output = _this$evaluateReturnE3.output;
+
+              _this.updateTextValue(el, output);
+
+              break;
+
+            default:
+              break;
           }
         });
       });
     }
   }, {
-    key: "evaluateExpressionWithEvent",
-    value: function evaluateExpressionWithEvent(expression, event) {
-      var mutatedDataItems = []; // Detect if the listener action mutated some data,
-      // this way we can selectively update bindings.
-
-      var proxiedData = new Proxy(this.data, {
-        set: function set(obj, property, value) {
-          var setWasSuccessful = Reflect.set(obj, property, value);
-          mutatedDataItems.push(property);
-          return setWasSuccessful;
-        }
-      });
-      Object(_utils__WEBPACK_IMPORTED_MODULE_0__["saferEvalNoReturn"])(expression, proxiedData, {
-        '$event': event
-      });
-      return mutatedDataItems;
-    }
-  }, {
-    key: "eventsThisComponentIsListeningFor",
-    value: function eventsThisComponentIsListeningFor() {
-      var eventsToListenFor = [];
-      Object(_utils__WEBPACK_IMPORTED_MODULE_0__["walk"])(this.el, function (el) {
-        eventsToListenFor = eventsToListenFor.concat(Array.from(el.attributes).map(function (i) {
-          return i.name;
-        }).filter(function (i) {
-          return i.search('x-on') > -1;
-        }).map(function (i) {
-          return i.replace(/x-on:/, '');
-        }));
-      });
-      return eventsToListenFor.filter(_utils__WEBPACK_IMPORTED_MODULE_0__["onlyUnique"]);
-    }
-  }, {
-    key: "updateBoundAttributes",
-    value: function updateBoundAttributes() {
+    key: "refresh",
+    value: function refresh() {
       var self = this;
       Object(_utils__WEBPACK_IMPORTED_MODULE_0__["debounce"])(_utils__WEBPACK_IMPORTED_MODULE_0__["walk"], 5)(this.el, function (el) {
-        Object(_utils__WEBPACK_IMPORTED_MODULE_0__["getXAttrs"])(el, 'bind').concat(Object(_utils__WEBPACK_IMPORTED_MODULE_0__["getXAttrs"])(el, 'text')).forEach(function (_ref2) {
+        Object(_utils__WEBPACK_IMPORTED_MODULE_0__["getXAttrs"])(el).forEach(function (_ref2) {
           var type = _ref2.type,
               value = _ref2.value,
               modifiers = _ref2.modifiers,
               expression = _ref2.expression;
-          var isConscernedWith = [];
-          var proxiedData = new Proxy(self.data, {
-            get: function get(object, prop) {
-              isConscernedWith.push(prop);
-              return object[prop];
-            }
-          });
-          var result = Object(_utils__WEBPACK_IMPORTED_MODULE_0__["saferEval"])(expression, proxiedData);
 
-          if (self.concernedData.filter(function (i) {
-            return isConscernedWith.includes(i);
-          }).length > 0) {
-            self.updateBoundAttributeValue(el, type, value, result);
+          switch (type) {
+            case 'bind':
+              var attrName = value;
+
+              var _self$evaluateReturnE = self.evaluateReturnExpression(expression),
+                  output = _self$evaluateReturnE.output,
+                  deps = _self$evaluateReturnE.deps;
+
+              if (self.concernedData.filter(function (i) {
+                return deps.includes(i);
+              }).length > 0) {
+                self.updateAttributeValue(el, attrName, output);
+              }
+
+              break;
+
+            case 'text':
+              var _self$evaluateReturnE2 = self.evaluateReturnExpression(expression),
+                  output = _self$evaluateReturnE2.output,
+                  deps = _self$evaluateReturnE2.deps;
+
+              if (self.concernedData.filter(function (i) {
+                return deps.includes(i);
+              }).length > 0) {
+                self.updateTextValue(el, output);
+              }
+
+              break;
+
+            default:
+              break;
           }
         });
       });
     }
   }, {
-    key: "updateAllBoundAttributes",
-    value: function updateAllBoundAttributes() {
+    key: "registerListener",
+    value: function registerListener(el, event, modifiers, expression) {
       var _this2 = this;
 
-      Object(_utils__WEBPACK_IMPORTED_MODULE_0__["walk"])(this.el, function (el) {
-        Object(_utils__WEBPACK_IMPORTED_MODULE_0__["getXAttrs"])(el, 'bind').concat(Object(_utils__WEBPACK_IMPORTED_MODULE_0__["getXAttrs"])(el, 'text')).forEach(function (_ref3) {
-          var type = _ref3.type,
-              value = _ref3.value,
-              modifiers = _ref3.modifiers,
-              expression = _ref3.expression;
-          var isConscernedWith = [];
-          var proxiedData = new Proxy(_this2.data, {
-            get: function get(object, prop) {
-              isConscernedWith.push(prop);
-              return object[prop];
-            }
-          });
-          var result = Object(_utils__WEBPACK_IMPORTED_MODULE_0__["saferEval"])(expression, proxiedData);
+      if (modifiers.includes('away')) {
+        // Listen for this event at the root level.
+        document.addEventListener(event, function (e) {
+          // Don't do anything if the click came form the element or within it.
+          if (el.contains(e.target)) return; // Don't do anything if this element isn't currently visible.
 
-          _this2.updateBoundAttributeValue(el, type, value, result);
+          if (el.offsetWidth < 1 && el.offsetHeight < 1) return; // Now that we are sure the element is visible, AND the click
+          // is from outside it, let's run the expression.
+
+          _this2.runListenerHandler(expression, e);
+        });
+      } else {
+        el.addEventListener(event, function (e) {
+          _this2.runListenerHandler(expression, e);
         });
+      }
+    }
+  }, {
+    key: "runListenerHandler",
+    value: function runListenerHandler(expression, e) {
+      var _this$concernedData;
+
+      var _this$evaluateCommand = this.evaluateCommandExpression(expression, {
+        '$event': e
+      }),
+          deps = _this$evaluateCommand.deps;
+
+      (_this$concernedData = this.concernedData).push.apply(_this$concernedData, _toConsumableArray(deps));
+
+      this.concernedData = this.concernedData.filter(_utils__WEBPACK_IMPORTED_MODULE_0__["onlyUnique"]);
+      this.refresh();
+    }
+  }, {
+    key: "evaluateReturnExpression",
+    value: function evaluateReturnExpression(expression) {
+      var affectedDataKeys = [];
+      var proxiedData = new Proxy(this.data, {
+        get: function get(object, prop) {
+          affectedDataKeys.push(prop);
+          return object[prop];
+        }
       });
+      var result = Object(_utils__WEBPACK_IMPORTED_MODULE_0__["saferEval"])(expression, proxiedData);
+      return {
+        output: result,
+        deps: affectedDataKeys
+      };
     }
   }, {
-    key: "updateBoundAttributeValue",
-    value: function updateBoundAttributeValue(el, type, attrName, value) {
-      if (type === 'text') {
-        el.innerText = value;
-      } else if (attrName === 'class') {
-        // Use the class object syntax that vue uses to toggle them.
-        Object.keys(value).forEach(function (className) {
-          if (value[className]) {
-            el.classList.add(className);
+    key: "evaluateCommandExpression",
+    value: function evaluateCommandExpression(expression, extraData) {
+      var affectedDataKeys = [];
+      var proxiedData = new Proxy(this.data, {
+        set: function set(obj, property, value) {
+          var setWasSuccessful = Reflect.set(obj, property, value);
+          affectedDataKeys.push(property);
+          return setWasSuccessful;
+        }
+      });
+      Object(_utils__WEBPACK_IMPORTED_MODULE_0__["saferEvalNoReturn"])(expression, proxiedData, extraData);
+      return {
+        deps: affectedDataKeys
+      };
+    }
+  }, {
+    key: "updateTextValue",
+    value: function updateTextValue(el, value) {
+      el.innerText = value;
+    }
+  }, {
+    key: "updateAttributeValue",
+    value: function updateAttributeValue(el, attrName, value) {
+      if (attrName === 'value') {
+        if (el.type === 'radio') {
+          el.checked = el.value == value;
+        } else if (el.type === 'checkbox') {
+          if (Array.isArray(value)) {
+            // I'm purposely not using Array.includes here because it's
+            // strict, and because of Numeric/String mis-casting, I
+            // want the "includes" to be "fuzzy".
+            var valueFound = false;
+            value.forEach(function (val) {
+              if (val == el.value) {
+                valueFound = true;
+              }
+            });
+            el.checked = valueFound;
           } else {
-            el.classList.remove(className);
+            el.checked = !!value;
           }
-        });
+        } else if (el.tagName === 'SELECT') {
+          this.updateSelect(el, value);
+        } else {
+          el.value = value;
+        }
+      } else if (attrName === 'class') {
+        if (Array.isArray(value)) {
+          el.setAttribute('class', value.join(' '));
+        } else {
+          // Use the class object syntax that vue uses to toggle them.
+          Object.keys(value).forEach(function (className) {
+            if (value[className]) {
+              el.classList.add(className);
+            } else {
+              el.classList.remove(className);
+            }
+          });
+        }
       } else if (['disabled', 'readonly', 'required', 'checked'].includes(attrName)) {
         // Boolean attributes have to be explicitly added and removed, not just set.
         if (!!value) {
@@ -288,6 +386,16 @@ function () {
         el.setAttribute(attrName, value);
       }
     }
+  }, {
+    key: "updateSelect",
+    value: function updateSelect(el, value) {
+      var arrayWrappedValue = [].concat(value).map(function (value) {
+        return value + '';
+      });
+      Array.from(el.options).forEach(function (option) {
+        option.selected = arrayWrappedValue.includes(option.value || option.text);
+      });
+    }
   }]);
 
   return Component;
@@ -313,6 +421,7 @@ var minimal = {
   start: function start() {
     var rootEls = document.querySelectorAll('[x-data]');
     rootEls.forEach(function (rootEl) {
+      // @todo - only set window.component in testing environments
       window.component = new _component__WEBPACK_IMPORTED_MODULE_0__["default"](rootEl);
     });
   }
@@ -389,12 +498,12 @@ function saferEvalNoReturn(expression, dataContext) {
   return new Function(['$data'].concat(_toConsumableArray(Object.keys(additionalHelperVariables))), "with($data) { ".concat(expression, " }")).apply(void 0, [dataContext].concat(_toConsumableArray(Object.values(additionalHelperVariables))));
 }
 function isXAttr(attr) {
-  var xAttrRE = /x-(on|bind|data|text)/;
+  var xAttrRE = /x-(on|bind|data|text|model)/;
   return xAttrRE.test(attr.name);
 }
-function getXAttrs(el, name) {
+function getXAttrs(el, type) {
   return Array.from(el.attributes).filter(isXAttr).map(function (attr) {
-    var typeMatch = attr.name.match(/x-(on|bind|data|text)/);
+    var typeMatch = attr.name.match(/x-(on|bind|data|text|model)/);
     var valueMatch = attr.name.match(/:([a-zA-Z\-]+)/);
     var modifiers = attr.name.match(/\.[^.\]]+(?=[^\]]*$)/g) || [];
     return {
@@ -406,6 +515,8 @@ function getXAttrs(el, name) {
       expression: attr.value
     };
   }).filter(function (i) {
+    // If no type is passed in for filtering, bypassfilter
+    if (!type) return true;
     return i.type === name;
   });
 }

+ 1 - 1
dist/mix-manifest.json

@@ -1,6 +1,6 @@
 {
     "/rename-me.js": "/rename-me.js?id=bbf39d4a044a5ded45f2",
     "/rename-me.min.js": "/rename-me.min.js?id=727c8cae5cc9ff82a386",
-    "/minimal.js": "/minimal.js?id=e5ea12f8b965950d4cc7",
+    "/minimal.js": "/minimal.js?id=106753738f90d6cd96e3",
     "/minimal.min.js": "/minimal.min.js?id=5456a8b662edbed0ae52"
 }

+ 29 - 3
index.html

@@ -5,19 +5,45 @@
         </style>
     </head>
     <body>
+        <div x-data="{ foo: ['bar', 'baz'] }">
+            <select x-model="foo" multiple>
+                <option disabled value="">Please select one</option>
+                <option>bar</option>
+                <option>baz</option>
+            </select>
+
+            <span x-text="foo"></span>
+        </div>
         <div x-data="{ currentTab: 'foo' }">
             <button x-bind:class="{ 'active': currentTab === 'foo' }" x-on:click="currentTab = 'foo'">Foo</button>
             <button x-bind:class="{ 'active': currentTab === 'bar' }" x-on:click="currentTab = 'bar'">Bar</button>
 
-            <div class="hidden" x-bind:class="{ 'hidden': currentTab !== 'foo' }">Tab Foo</div>
+            <div x-bind:class="{ 'hidden': currentTab !== 'foo' }">Tab Foo</div>
             <div class="hidden" x-bind:class="{ 'hidden': currentTab !== 'bar' }">Tab Bar</div>
         </div>
 
-        <div x-data="{ name: 'bar' }">
-            <input type="text" x-on:input="name = $event.target.value">
+        <div x-data="{ foo: 'bar' }">
+            <input type="radio" x-model="foo" value="bar"></input>
+            <input type="radio" x-model="foo" value="baz"></input>
+
+            <span x-text="foo"></span>
+        </div>
+
+
+        <div x-data="{ name: 1 }">
+            <input type="text" x-model.number="name">
 
             <span x-text="name"></span>
         </div>
+        <div x-data="{ foo: [] }">
+            <input type="checkbox" x-model="foo" value="bar"></input>
+            <input type="checkbox" x-model="foo" value="baz"></input>
+
+            <span x-text="foo"></span>
+        </div>
+        <div x-data="{}">
+            <span class="" x-bind:class="['hey']"></span>
+        </div>
 
         <script src="/dist/minimal.js"></script>
         <script>

+ 186 - 96
src/component.js

@@ -8,134 +8,216 @@ export default class Component {
 
         this.concernedData = []
 
-        this.registerListeners()
-
-        this.updateAllBoundAttributes()
+        this.initialize()
     }
 
-    registerListeners() {
+    initialize() {
         walk(this.el, el => {
-            getXAttrs(el, 'on').forEach(({ type, value, modifiers, expression }) => {
-                if (modifiers.includes('away')) {
-                    // Listen for this event at the root level.
-                    document.addEventListener(value, e => {
-                        // Don't do anything if the click came form the element or within it.
-                        if (el.contains(e.target)) return
-
-                        // Don't do anything if this element isn't currently visible.
-                        if (el.offsetWidth < 1 && el.offsetHeight < 1) return
-
-                        // Now that we are sure the element is visible, AND the click
-                        // is from outside it, let's run the expression.
-                        this.concernedData.push(...this.evaluateExpressionWithEvent(expression, e))
-                        this.concernedData = this.concernedData.filter(onlyUnique)
-
-                        this.updateBoundAttributes()
-                    })
-                } else {
-                    el.addEventListener(value, e => {
-                        this.concernedData.push(...this.evaluateExpressionWithEvent(expression, e))
-                        this.concernedData = this.concernedData.filter(onlyUnique)
+            getXAttrs(el).forEach(({ type, value, modifiers, expression }) => {
+                switch (type) {
+                    case 'on':
+                        var event = value
+                        this.registerListener(el, event, modifiers, expression)
+                        break;
+
+                    case 'model':
+                        // If the element we are binding to is a select, a radio, or checkbox
+                        // we'll listen for the change event instead of the "input" event.
+                        var event = (
+                            el.tagName.toLowerCase() === 'select')
+                            || (['checkbox', 'radio'].includes(el.type)
+                            || modifiers.includes('lazy')
+                            )
+                                ? 'change' : 'input'
+
+                        var rightSideOfExpression = ''
+                        if (el.type === 'checkbox') {
+                            // If the data we are binding to is an array, toggle it's value inside the array.
+                            if (Array.isArray(this.data[expression])) {
+                                rightSideOfExpression = `$event.target.checked ? ${expression}.concat([$event.target.value]) : [...${expression}.splice(0, ${expression}.indexOf($event.target.value)), ...${expression}.splice(${expression}.indexOf($event.target.value)+1)]`
+                            } else {
+                                rightSideOfExpression = `$event.target.checked`
+                            }
+                        } else if (el.tagName.toLowerCase() === 'select' && el.multiple) {
+                            rightSideOfExpression = modifiers.includes('number')
+                                ? 'Array.from($event.target.selectedOptions).map(option => { return parseFloat(option.value || option.text) })'
+                                : 'Array.from($event.target.selectedOptions).map(option => { return option.value || option.text })'
+                        } else {
+                            rightSideOfExpression = modifiers.includes('number')
+                                ? 'parseFloat($event.target.value)'
+                                : (modifiers.includes('trim') ? '$event.target.value.trim()' : '$event.target.value')
+                        }
+
+                        if (el.type === 'radio') {
+                            // Radio buttons only work properly when they share a name attribute.
+                            // People might assume we take care of that for them, because
+                            // they already set a shared "x-model" attribute.
+                            if (! el.hasAttribute('name')) el.setAttribute('name', expression)
+                        }
+
+                        this.registerListener(el, event, modifiers, `${expression} = ${rightSideOfExpression}`)
+
+                        var attrName = 'value'
+                        var { output } = this.evaluateReturnExpression(expression)
+                        this.updateAttributeValue(el, attrName, output)
+                        break;
+
+                    case 'bind':
+                        var attrName = value
+                        var { output } = this.evaluateReturnExpression(expression)
+                        this.updateAttributeValue(el, attrName, output)
+                        break;
+
+                    case 'text':
+                        var { output } = this.evaluateReturnExpression(expression)
+                        this.updateTextValue(el, output)
+                        break;
+
+                    default:
+                        break;
+                }
+            })
+        })
+    }
 
-                        this.updateBoundAttributes()
-                    })
+    refresh() {
+        var self = this
+        debounce(walk, 5)(this.el, function (el) {
+            getXAttrs(el).forEach(({ type, value, modifiers, expression }) => {
+                switch (type) {
+                    case 'bind':
+                        const attrName = value
+                        var { output, deps } = self.evaluateReturnExpression(expression)
+
+                        if (self.concernedData.filter(i => deps.includes(i)).length > 0) {
+                            self.updateAttributeValue(el, attrName, output)
+                        }
+                        break;
+
+                    case 'text':
+                        var { output, deps } = self.evaluateReturnExpression(expression)
+
+                        if (self.concernedData.filter(i => deps.includes(i)).length > 0) {
+                            self.updateTextValue(el, output)
+                        }
+                        break;
+
+                    default:
+                        break;
                 }
             })
         })
     }
 
-    evaluateExpressionWithEvent(expression, event) {
-        var mutatedDataItems = []
+    registerListener(el, event, modifiers, expression) {
+        if (modifiers.includes('away')) {
+            // Listen for this event at the root level.
+            document.addEventListener(event, e => {
+                // Don't do anything if the click came form the element or within it.
+                if (el.contains(e.target)) return
 
-        // Detect if the listener action mutated some data,
-        // this way we can selectively update bindings.
-        const proxiedData = new Proxy(this.data, {
-            set(obj, property, value) {
-                const setWasSuccessful = Reflect.set(obj, property, value)
+                // Don't do anything if this element isn't currently visible.
+                if (el.offsetWidth < 1 && el.offsetHeight < 1) return
 
-                mutatedDataItems.push(property)
+                // Now that we are sure the element is visible, AND the click
+                // is from outside it, let's run the expression.
+                this.runListenerHandler(expression, e)
+            })
+        } else {
+            el.addEventListener(event, e => {
+                this.runListenerHandler(expression, e)
+            })
+        }
+    }
 
-                return setWasSuccessful
-            }
-        })
+    runListenerHandler(expression, e) {
+        const { deps } = this.evaluateCommandExpression(expression, { '$event': e })
 
-        saferEvalNoReturn(expression, proxiedData, {
-            '$event': event,
-        })
+        this.concernedData.push(...deps)
+        this.concernedData = this.concernedData.filter(onlyUnique)
 
-        return mutatedDataItems;
+        this.refresh()
     }
 
-    eventsThisComponentIsListeningFor() {
-        var eventsToListenFor = []
+    evaluateReturnExpression(expression) {
+        var affectedDataKeys = []
 
-        walk(this.el, el => {
-            eventsToListenFor = eventsToListenFor.concat(
-                Array.from(el.attributes)
-                    .map(i => i.name)
-                    .filter(i => i.search('x-on') > -1)
-                    .map(i => i.replace(/x-on:/, ''))
-            )
+        const proxiedData = new Proxy(this.data, {
+            get(object, prop) {
+                affectedDataKeys.push(prop)
+
+                return object[prop]
+            }
         })
 
-        return eventsToListenFor.filter(onlyUnique)
-    }
+        const result = saferEval(expression, proxiedData)
 
-    updateBoundAttributes() {
-        var self = this
-        debounce(walk, 5)(this.el, function (el) {
-            getXAttrs(el, 'bind').concat(getXAttrs(el, 'text')).forEach(({ type, value, modifiers, expression }) => {
-                var isConscernedWith = []
+        return {
+            output: result,
+            deps: affectedDataKeys
+        }
+    }
 
-                const proxiedData = new Proxy(self.data, {
-                    get(object, prop) {
-                        isConscernedWith.push(prop)
+    evaluateCommandExpression(expression, extraData) {
+        var affectedDataKeys = []
 
-                        return object[prop]
-                    }
-                })
+        const proxiedData = new Proxy(this.data, {
+            set(obj, property, value) {
+                const setWasSuccessful = Reflect.set(obj, property, value)
 
-                const result = saferEval(expression, proxiedData)
+                affectedDataKeys.push(property)
 
-                if (self.concernedData.filter(i => isConscernedWith.includes(i)).length > 0) {
-                    self.updateBoundAttributeValue(el, type, value, result)
-                }
-            })
+                return setWasSuccessful
+            }
         })
-    }
-
-    updateAllBoundAttributes() {
-        walk(this.el, el => {
-            getXAttrs(el, 'bind').concat(getXAttrs(el, 'text')).forEach(({ type, value, modifiers, expression }) => {
-                var isConscernedWith = []
-
-                const proxiedData = new Proxy(this.data, {
-                    get(object, prop) {
-                        isConscernedWith.push(prop)
 
-                        return object[prop]
-                    }
-                })
+        saferEvalNoReturn(expression, proxiedData, extraData)
 
-                const result = saferEval(expression, proxiedData)
+        return { deps: affectedDataKeys }
+    }
 
-                this.updateBoundAttributeValue(el, type, value, result)
-            })
-        })
+    updateTextValue(el, value) {
+        el.innerText = value
     }
 
-    updateBoundAttributeValue(el, type, attrName, value) {
-        if (type === 'text') {
-            el.innerText = value
-        } else if (attrName === 'class') {
-            // Use the class object syntax that vue uses to toggle them.
-            Object.keys(value).forEach(className => {
-                if (value[className]) {
-                    el.classList.add(className)
+    updateAttributeValue(el, attrName, value) {
+        if (attrName === 'value') {
+            if (el.type === 'radio') {
+                el.checked = el.value == value
+            } else if (el.type === 'checkbox') {
+                if (Array.isArray(value)) {
+                    // I'm purposely not using Array.includes here because it's
+                    // strict, and because of Numeric/String mis-casting, I
+                    // want the "includes" to be "fuzzy".
+                    let valueFound = false
+                    value.forEach(val => {
+                        if (val == el.value) {
+                            valueFound = true
+                        }
+                    })
+
+                    el.checked = valueFound
                 } else {
-                    el.classList.remove(className)
+                    el.checked = !! value
                 }
-            })
+            } else if (el.tagName === 'SELECT') {
+                this.updateSelect(el, value)
+            } else {
+                el.value = value
+            }
+        } else if (attrName === 'class') {
+            if (Array.isArray(value)) {
+                el.setAttribute('class', value.join(' '))
+            } else {
+                // Use the class object syntax that vue uses to toggle them.
+                Object.keys(value).forEach(className => {
+                    if (value[className]) {
+                        el.classList.add(className)
+                    } else {
+                        el.classList.remove(className)
+                    }
+                })
+            }
         } else if (['disabled', 'readonly', 'required', 'checked'].includes(attrName)) {
             // Boolean attributes have to be explicitly added and removed, not just set.
             if (!! value) {
@@ -147,4 +229,12 @@ export default class Component {
             el.setAttribute(attrName, value)
         }
     }
+
+    updateSelect(el, value) {
+        const arrayWrappedValue = [].concat(value).map(value => { return value + '' })
+
+        Array.from(el.options).forEach(option => {
+            option.selected = arrayWrappedValue.includes(option.value || option.text)
+        })
+    }
 }

+ 1 - 0
src/index.js

@@ -6,6 +6,7 @@ const minimal = {
         const rootEls = document.querySelectorAll('[x-data]');
 
         rootEls.forEach(rootEl => {
+            // @todo - only set window.component in testing environments
             window.component = new Component(rootEl)
         })
     }

+ 9 - 4
src/utils.js

@@ -42,16 +42,16 @@ export function saferEvalNoReturn(expression, dataContext, additionalHelperVaria
 }
 
 export function isXAttr(attr) {
-    const xAttrRE = /x-(on|bind|data|text)/
+    const xAttrRE = /x-(on|bind|data|text|model)/
 
     return xAttrRE.test(attr.name)
 }
 
-export function getXAttrs(el, name) {
+export function getXAttrs(el, type) {
     return Array.from(el.attributes)
         .filter(isXAttr)
         .map(attr => {
-            const typeMatch = attr.name.match(/x-(on|bind|data|text)/)
+            const typeMatch = attr.name.match(/x-(on|bind|data|text|model)/)
             const valueMatch = attr.name.match(/:([a-zA-Z\-]+)/)
             const modifiers = attr.name.match(/\.[^.\]]+(?=[^\]]*$)/g) || []
 
@@ -62,5 +62,10 @@ export function getXAttrs(el, name) {
                 expression: attr.value,
             }
         })
-        .filter(i => i.type === name)
+        .filter(i => {
+            // If no type is passed in for filtering, bypassfilter
+            if (! type) return true
+
+            return i.type === name
+        })
 }

+ 97 - 0
test/bind.spec.js

@@ -0,0 +1,97 @@
+import minimal from 'minimal'
+
+test('attribute bindings are set on initialize', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ foo: 'bar' }">
+            <span x-bind:foo="foo"></span>
+        </div>
+    `
+
+    minimal.start()
+
+    expect(document.querySelector('span').getAttribute('foo')).toEqual('bar')
+})
+
+test('class attribute bindings are removed by object syntax', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ isOn: false }">
+            <span class="foo" x-bind:class="{ 'foo': isOn }"></span>
+        </div>
+    `
+
+    minimal.start()
+
+    expect(document.querySelector('span').classList.contains('foo')).toBeFalsy()
+})
+
+test('class attribute bindings are added by object syntax', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ isOn: true }">
+            <span x-bind:class="{ 'foo': isOn }"></span>
+        </div>
+    `
+
+    minimal.start()
+
+    expect(document.querySelector('span').classList.contains('foo')).toBeTruthy()
+})
+
+test('class attribute bindings are removed by array syntax', async () => {
+    document.body.innerHTML = `
+        <div x-data="{}">
+            <span class="foo" x-bind:class="[]"></span>
+        </div>
+    `
+
+    minimal.start()
+
+    expect(document.querySelector('span').classList.contains('foo')).toBeFalsy()
+})
+
+test('class attribute bindings are added by array syntax', async () => {
+    document.body.innerHTML = `
+        <div x-data="{}">
+            <span class="" x-bind:class="['foo']"></span>
+        </div>
+    `
+
+    minimal.start()
+
+    expect(document.querySelector('span').classList.contains('foo')).toBeTruthy
+})
+
+test('boolean attributes set to false are removed from element', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ isSet: false }">
+            <input x-bind:disabled="isSet"></input>
+            <input x-bind:checked="isSet"></input>
+            <input x-bind:required="isSet"></input>
+            <input x-bind:readonly="isSet"></input>
+        </div>
+    `
+
+    minimal.start()
+
+    expect(document.querySelectorAll('input')[0].disabled).toBeFalsy()
+    expect(document.querySelectorAll('input')[1].checked).toBeFalsy()
+    expect(document.querySelectorAll('input')[2].required).toBeFalsy()
+    expect(document.querySelectorAll('input')[3].readOnly).toBeFalsy()
+})
+
+test('boolean attributes set to true are added to element', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ isSet: true }">
+            <input x-bind:disabled="isSet"></input>
+            <input x-bind:checked="isSet"></input>
+            <input x-bind:required="isSet"></input>
+            <input x-bind:readonly="isSet"></input>
+        </div>
+    `
+
+    minimal.start()
+
+    expect(document.querySelectorAll('input')[0].disabled).toBeTruthy()
+    expect(document.querySelectorAll('input')[1].checked).toBeTruthy()
+    expect(document.querySelectorAll('input')[2].required).toBeTruthy()
+    expect(document.querySelectorAll('input')[3].readOnly).toBeTruthy()
+})

+ 202 - 0
test/model.spec.js

@@ -0,0 +1,202 @@
+import minimal from 'minimal'
+import { wait, fireEvent } from 'dom-testing-library'
+
+test('x-model has value binding when initialized', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ foo: 'bar' }">
+            <input x-model="foo"></input>
+        </div>
+    `
+
+    minimal.start()
+
+    expect(document.querySelector('input').value).toEqual('bar')
+})
+
+test('x-model updates value when updated via input event', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ foo: 'bar' }">
+            <input x-model="foo"></input>
+        </div>
+    `
+
+    minimal.start()
+
+    fireEvent.input(document.querySelector('input'), { target: { value: 'baz' }})
+
+    await wait(() => { expect(document.querySelector('input').value).toEqual('baz') })
+})
+
+test('x-model casts value to number if number modifier is present', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ foo: null }">
+            <input type="number" x-model.number="foo"></input>
+        </div>
+    `
+
+    minimal.start()
+
+    fireEvent.input(document.querySelector('input'), { target: { value: '123' }})
+
+    await wait(() => { expect(window.component.data.foo).toEqual(123) })
+})
+
+test('x-model trims value if trim modifier is present', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ foo: '' }">
+            <input x-model.trim="foo"></input>
+
+            <span x-text="foo"></span>
+        </div>
+    `
+
+    minimal.start()
+
+    fireEvent.input(document.querySelector('input'), { target: { value: 'bar   ' }})
+
+    await wait(() => { expect(document.querySelector('span').innerText).toEqual('bar') })
+})
+
+test('x-model updates value when updated via changed event when lazy modifier is present', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ foo: 'bar' }">
+            <input x-model.lazy="foo"></input>
+        </div>
+    `
+
+    minimal.start()
+
+    fireEvent.change(document.querySelector('input'), { target: { value: 'baz' }})
+
+    await wait(() => { expect(document.querySelector('input').value).toEqual('baz') })
+})
+
+test('x-model binds checkbox value', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ foo: true }">
+            <input type="checkbox" x-model="foo"></input>
+
+            <span x-bind:bar="foo"></span>
+        </div>
+    `
+
+    minimal.start()
+
+    expect(document.querySelector('input').checked).toEqual(true)
+    expect(document.querySelector('span').getAttribute('bar')).toEqual("true")
+
+    fireEvent.change(document.querySelector('input'), { target: { checked: false }})
+
+    await wait(() => { expect(document.querySelector('span').getAttribute('bar')).toEqual("false") })
+})
+
+test('x-model binds checkbox value to array', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ foo: ['bar'] }">
+            <input type="checkbox" x-model="foo" value="bar"></input>
+            <input type="checkbox" x-model="foo" value="baz"></input>
+
+            <span x-bind:bar="foo"></span>
+        </div>
+    `
+
+    minimal.start()
+
+    expect(document.querySelectorAll('input')[0].checked).toEqual(true)
+    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 }})
+
+    await wait(() => {
+        expect(document.querySelectorAll('input')[0].checked).toEqual(true)
+        expect(document.querySelectorAll('input')[1].checked).toEqual(true)
+        expect(document.querySelector('span').getAttribute('bar')).toEqual("bar,baz")
+    })
+})
+
+test('x-model binds radio value', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ foo: 'bar' }">
+            <input type="radio" x-model="foo" value="bar"></input>
+            <input type="radio" x-model="foo" value="baz"></input>
+
+            <span x-bind:bar="foo"></span>
+        </div>
+    `
+
+    minimal.start()
+
+    expect(document.querySelectorAll('input')[0].checked).toEqual(true)
+    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 }})
+
+    await wait(() => {
+        expect(document.querySelectorAll('input')[0].checked).toEqual(false)
+        expect(document.querySelectorAll('input')[1].checked).toEqual(true)
+        expect(document.querySelector('span').getAttribute('bar')).toEqual('baz')
+    })
+})
+
+test('x-model binds select dropdown', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ foo: 'bar' }">
+            <select x-model="foo">
+                <option disabled value="">Please select one</option>
+                <option>bar</option>
+                <option>baz</option>
+            </select>
+
+            <span x-text="foo"></span>
+        </div>
+    `
+
+    minimal.start()
+
+    expect(document.querySelectorAll('option')[0].selected).toEqual(false)
+    expect(document.querySelectorAll('option')[1].selected).toEqual(true)
+    expect(document.querySelectorAll('option')[2].selected).toEqual(false)
+    expect(document.querySelector('span').innerText).toEqual('bar')
+
+    fireEvent.change(document.querySelector('select'), { target: { value: 'baz' }});
+
+    await wait(() => {
+        expect(document.querySelectorAll('option')[0].selected).toEqual(false)
+        expect(document.querySelectorAll('option')[1].selected).toEqual(false)
+        expect(document.querySelectorAll('option')[2].selected).toEqual(true)
+        expect(document.querySelector('span').innerText).toEqual('baz')
+    })
+})
+
+test('x-model binds multiple select dropdown', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ foo: ['bar'] }">
+            <select x-model="foo" multiple>
+                <option disabled value="">Please select one</option>
+                <option>bar</option>
+                <option>baz</option>
+            </select>
+
+            <span x-text="foo"></span>
+        </div>
+    `
+
+    minimal.start()
+
+    expect(document.querySelectorAll('option')[0].selected).toEqual(false)
+    expect(document.querySelectorAll('option')[1].selected).toEqual(true)
+    expect(document.querySelectorAll('option')[2].selected).toEqual(false)
+    expect(document.querySelector('span').innerText).toEqual(['bar'])
+
+    document.querySelectorAll('option')[2].selected = true
+    fireEvent.change(document.querySelector('select'));
+
+    await wait(() => {
+        expect(document.querySelectorAll('option')[0].selected).toEqual(false)
+        expect(document.querySelectorAll('option')[1].selected).toEqual(true)
+        expect(document.querySelectorAll('option')[2].selected).toEqual(true)
+        expect(document.querySelector('span').innerText).toEqual(['bar', 'baz'])
+    })
+})

+ 85 - 0
test/on.spec.js

@@ -0,0 +1,85 @@
+import minimal from 'minimal'
+import { wait } from 'dom-testing-library'
+
+test('data modified in event listener updates effected attribute bindings', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ foo: 'bar' }">
+            <button x-on:click="foo = 'baz'"></button>
+
+            <span x-bind:foo="foo"></span>
+        </div>
+    `
+
+    minimal.start()
+
+    expect(document.querySelector('span').getAttribute('foo')).toEqual('bar')
+
+    document.querySelector('button').click()
+
+    await wait(() => { expect(document.querySelector('span').getAttribute('foo')).toEqual('baz') })
+})
+
+test('data modified in event listener doesnt update uneffected attribute bindings', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ foo: 'bar', count: 0 }">
+            <button x-on:click="foo = 'baz'"></button>
+            <button x-on:click="count++"></button>
+
+            <span x-bind:output="foo"></span>
+            <span x-bind:output="count++"></span>
+        </div>
+    `
+
+    minimal.start()
+
+    expect(document.querySelectorAll('span')[0].getAttribute('output')).toEqual('bar')
+    expect(document.querySelectorAll('span')[1].getAttribute('output')).toEqual('0')
+
+    document.querySelectorAll('button')[0].click()
+
+    await wait(async () => {
+        expect(document.querySelectorAll('span')[0].getAttribute('output')).toEqual('baz')
+        expect(document.querySelectorAll('span')[1].getAttribute('output')).toEqual('0')
+
+        document.querySelectorAll('button')[1].click()
+
+        await wait(() => {
+            expect(document.querySelectorAll('span')[0].getAttribute('output')).toEqual('baz')
+            expect(document.querySelectorAll('span')[1].getAttribute('output')).toEqual('3')
+        })
+    })
+})
+
+test('click away', async () => {
+    document.body.innerHTML = `
+        <div id="outer">
+            <div x-data="{ isOpen: true }">
+                <button x-on:click="isOpen = true"></button>
+
+                <ul x-bind:value="isOpen" x-on:click.away="isOpen = false">
+                    <li>...</li>
+                </ul>
+            </div>
+        </div>
+    `
+
+    minimal.start()
+
+    expect(document.querySelector('ul').getAttribute('value')).toEqual('true')
+
+    document.querySelector('li').click()
+
+    await wait(() => { expect(document.querySelector('ul').getAttribute('value')).toEqual('true') })
+
+    document.querySelector('ul').click()
+
+    await wait(() => { expect(document.querySelector('ul').getAttribute('value')).toEqual('true') })
+
+    document.querySelector('#outer').click()
+
+    await wait(() => { expect(document.querySelector('ul').getAttribute('value')).toEqual('false') })
+
+    document.querySelector('button').click()
+
+    await wait(() => { expect(document.querySelector('ul').getAttribute('value')).toEqual('true') })
+})

+ 0 - 187
test/test.spec.js

@@ -1,187 +0,0 @@
-import minimal from 'minimal'
-import { wait } from 'dom-testing-library'
-
-test('attribute bindings are set on initialize', async () => {
-    document.body.innerHTML = `
-        <div x-data="{ foo: 'bar' }">
-            <span x-bind:foo="foo"></span>
-        </div>
-    `
-
-    minimal.start()
-
-    expect(document.querySelector('span').getAttribute('foo')).toEqual('bar')
-})
-
-test('x-text on init', async () => {
-    document.body.innerHTML = `
-        <div x-data="{ foo: 'bar' }">
-            <span x-text="foo"></span>
-        </div>
-    `
-
-    minimal.start()
-
-    await wait(() => { expect(document.querySelector('span').innerText).toEqual('bar') })
-})
-
-test('x-text on triggered update', async () => {
-    document.body.innerHTML = `
-        <div x-data="{ foo: '' }">
-            <button x-on:click="foo = 'bar'"></button>
-
-            <span x-text="foo"></span>
-        </div>
-    `
-
-    minimal.start()
-
-    await wait(() => { expect(document.querySelector('span').innerText).toEqual('') })
-
-    document.querySelector('button').click()
-
-    await wait(() => { expect(document.querySelector('span').innerText).toEqual('bar') })
-})
-
-test('class attribute bindings are removed by object syntax', async () => {
-    document.body.innerHTML = `
-        <div x-data="{ isOn: false }">
-            <span class="foo" x-bind:class="{ 'foo': isOn }"></span>
-        </div>
-    `
-
-    minimal.start()
-
-    expect(document.querySelector('span').classList.contains('foo')).toBeFalsy
-})
-
-test('class attribute bindings are added by object syntax', async () => {
-    document.body.innerHTML = `
-        <div x-data="{ isOn: true }">
-            <span x-bind:class="{ 'foo': isOn }"></span>
-        </div>
-    `
-
-    minimal.start()
-
-    expect(document.querySelector('span').classList.contains('foo')).toBeTruthy
-})
-
-test('boolean attributes set to false are removed from element', async () => {
-    document.body.innerHTML = `
-        <div x-data="{ isSet: false }">
-            <input x-bind:disabled="isSet"></input>
-            <input x-bind:checked="isSet"></input>
-            <input x-bind:required="isSet"></input>
-            <input x-bind:readonly="isSet"></input>
-        </div>
-    `
-
-    minimal.start()
-
-    expect(document.querySelectorAll('input')[0].disabled).toBeFalsy()
-    expect(document.querySelectorAll('input')[1].checked).toBeFalsy()
-    expect(document.querySelectorAll('input')[2].required).toBeFalsy()
-    expect(document.querySelectorAll('input')[3].readOnly).toBeFalsy()
-})
-
-test('boolean attributes set to true are added to element', async () => {
-    document.body.innerHTML = `
-        <div x-data="{ isSet: true }">
-            <input x-bind:disabled="isSet"></input>
-            <input x-bind:checked="isSet"></input>
-            <input x-bind:required="isSet"></input>
-            <input x-bind:readonly="isSet"></input>
-        </div>
-    `
-
-    minimal.start()
-
-    expect(document.querySelectorAll('input')[0].disabled).toBeTruthy()
-    expect(document.querySelectorAll('input')[1].checked).toBeTruthy()
-    expect(document.querySelectorAll('input')[2].required).toBeTruthy()
-    expect(document.querySelectorAll('input')[3].readOnly).toBeTruthy()
-})
-
-test('data modified in event listener updates effected attribute bindings', async () => {
-    document.body.innerHTML = `
-        <div x-data="{ foo: 'bar' }">
-            <button x-on:click="foo = 'baz'"></button>
-
-            <span x-bind:foo="foo"></span>
-        </div>
-    `
-
-    minimal.start()
-
-    expect(document.querySelector('span').getAttribute('foo')).toEqual('bar')
-
-    document.querySelector('button').click()
-
-    await wait(() => { expect(document.querySelector('span').getAttribute('foo')).toEqual('baz') })
-})
-
-test('data modified in event listener doesnt update uneffected attribute bindings', async () => {
-    document.body.innerHTML = `
-        <div x-data="{ foo: 'bar', count: 0 }">
-            <button x-on:click="foo = 'baz'"></button>
-            <button x-on:click="count++"></button>
-
-            <span x-bind:value="foo"></span>
-            <span x-bind:value="count++"></span>
-        </div>
-    `
-
-    minimal.start()
-
-    expect(document.querySelectorAll('span')[0].getAttribute('value')).toEqual('bar')
-    expect(document.querySelectorAll('span')[1].getAttribute('value')).toEqual('0')
-
-    document.querySelectorAll('button')[0].click()
-
-    await wait(async () => {
-        expect(document.querySelectorAll('span')[0].getAttribute('value')).toEqual('baz')
-        expect(document.querySelectorAll('span')[1].getAttribute('value')).toEqual('0')
-
-        document.querySelectorAll('button')[1].click()
-
-        await wait(() => {
-            expect(document.querySelectorAll('span')[0].getAttribute('value')).toEqual('baz')
-            expect(document.querySelectorAll('span')[1].getAttribute('value')).toEqual('3')
-        })
-    })
-})
-
-test('click away', async () => {
-    document.body.innerHTML = `
-        <div id="outer">
-            <div x-data="{ isOpen: true }">
-                <button x-on:click="isOpen = true"></button>
-
-                <ul x-bind:value="isOpen" x-on:click.away="isOpen = false">
-                    <li>...</li>
-                </ul>
-            </div>
-        </div>
-    `
-
-    minimal.start()
-
-    expect(document.querySelector('ul').getAttribute('value')).toEqual('true')
-
-    document.querySelector('li').click()
-
-    await wait(() => { expect(document.querySelector('ul').getAttribute('value')).toEqual('true') })
-
-    document.querySelector('ul').click()
-
-    await wait(() => { expect(document.querySelector('ul').getAttribute('value')).toEqual('true') })
-
-    document.querySelector('#outer').click()
-
-    await wait(() => { expect(document.querySelector('ul').getAttribute('value')).toEqual('false') })
-
-    document.querySelector('button').click()
-
-    await wait(() => { expect(document.querySelector('ul').getAttribute('value')).toEqual('true') })
-})

+ 32 - 0
test/text.spec.js

@@ -0,0 +1,32 @@
+import minimal from 'minimal'
+import { wait } from 'dom-testing-library'
+
+test('x-text on init', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ foo: 'bar' }">
+            <span x-text="foo"></span>
+        </div>
+    `
+
+    minimal.start()
+
+    await wait(() => { expect(document.querySelector('span').innerText).toEqual('bar') })
+})
+
+test('x-text on triggered update', async () => {
+    document.body.innerHTML = `
+        <div x-data="{ foo: '' }">
+            <button x-on:click="foo = 'bar'"></button>
+
+            <span x-text="foo"></span>
+        </div>
+    `
+
+    minimal.start()
+
+    await wait(() => { expect(document.querySelector('span').innerText).toEqual('') })
+
+    document.querySelector('button').click()
+
+    await wait(() => { expect(document.querySelector('span').innerText).toEqual('bar') })
+})