Skip to content

Commit

Permalink
Add textInput binding.
Browse files Browse the repository at this point in the history
  • Loading branch information
mbest committed Jun 13, 2014
1 parent 6ecec35 commit 5c932e2
Show file tree
Hide file tree
Showing 5 changed files with 375 additions and 1 deletion.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ desktop.ini
.eprj
perf/*
*.orig

*.bak
.DS_Store
npm-debug.log
node_modules
Expand Down
1 change: 1 addition & 0 deletions build/fragments/source-references.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ knockoutDebugCallback([
'src/binding/defaultBindings/style.js',
'src/binding/defaultBindings/submit.js',
'src/binding/defaultBindings/text.js',
'src/binding/defaultBindings/textInput.js',
'src/binding/defaultBindings/uniqueName.js',
'src/binding/defaultBindings/value.js',
'src/binding/defaultBindings/visible.js',
Expand Down
225 changes: 225 additions & 0 deletions spec/defaultBindings/textInputBehaviors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
describe('Binding: TextInput', function() {
beforeEach(jasmine.prepareTestNode);

it('Should assign the value to the node', function () {
testNode.innerHTML = "<input data-bind='textInput:123' />";
ko.applyBindings(null, testNode);
expect(testNode.childNodes[0].value).toEqual("123");
});

it('Should treat null values as empty strings', function () {
testNode.innerHTML = "<input data-bind='textInput:myProp' />";
ko.applyBindings({ myProp: ko.observable(0) }, testNode);
expect(testNode.childNodes[0].value).toEqual("0");
});

it('Should assign an empty string as value if the model value is null', function () {
testNode.innerHTML = "<input data-bind='textInput:(null)' />";
ko.applyBindings(null, testNode);
expect(testNode.childNodes[0].value).toEqual("");
});

it('Should assign an empty string as value if the model value is undefined', function () {
testNode.innerHTML = "<input data-bind='textInput:undefined' />";
ko.applyBindings(null, testNode);
expect(testNode.childNodes[0].value).toEqual("");
});

it('For observable values, should unwrap the value and update on change', function () {
var myobservable = new ko.observable(123);
testNode.innerHTML = "<input data-bind='textInput:someProp' />";
ko.applyBindings({ someProp: myobservable }, testNode);
expect(testNode.childNodes[0].value).toEqual("123");
myobservable(456);
expect(testNode.childNodes[0].value).toEqual("456");
});

it('For observable values, should update on change if new value is \'strictly\' different from previous value', function() {
var myobservable = new ko.observable("+123");
testNode.innerHTML = "<input data-bind='textInput:someProp' />";
ko.applyBindings({ someProp: myobservable }, testNode);
expect(testNode.childNodes[0].value).toEqual("+123");
myobservable(123);
expect(testNode.childNodes[0].value).toEqual("123");
});

it('For writeable observable values, should catch the node\'s onchange and write values back to the observable', function () {
var myobservable = new ko.observable(123);
testNode.innerHTML = "<input data-bind='textInput:someProp' />";
ko.applyBindings({ someProp: myobservable }, testNode);
testNode.childNodes[0].value = "some user-entered value";
ko.utils.triggerEvent(testNode.childNodes[0], "change");
expect(myobservable()).toEqual("some user-entered value");
});

it('For writeable observable values, when model rejects change, update view to match', function () {
var validValue = ko.observable(123);
var isValid = ko.observable(true);
var valueForEditing = ko.computed({
read: validValue,
write: function(newValue) {
if (!isNaN(newValue)) {
isValid(true);
validValue(newValue);
} else {
isValid(false);
}
}
});

testNode.innerHTML = "<input data-bind='textInput: valueForEditing' />";
ko.applyBindings({ valueForEditing: valueForEditing}, testNode);

//set initial valid value
testNode.childNodes[0].value = "1234";
ko.utils.triggerEvent(testNode.childNodes[0], "change");
expect(validValue()).toEqual("1234");
expect(isValid()).toEqual(true);
expect(testNode.childNodes[0].value).toEqual("1234");

//set to an invalid value
testNode.childNodes[0].value = "1234a";
ko.utils.triggerEvent(testNode.childNodes[0], "change");
expect(validValue()).toEqual("1234");
expect(isValid()).toEqual(false);
expect(testNode.childNodes[0].value).toEqual("1234a");

//set to a valid value where the current value of the writeable computed is the same as the written value
testNode.childNodes[0].value = "1234";
ko.utils.triggerEvent(testNode.childNodes[0], "change");
expect(validValue()).toEqual("1234");
expect(isValid()).toEqual(true);
expect(testNode.childNodes[0].value).toEqual("1234");
});

it('Should ignore node changes when bound to a read-only observable', function() {
var computedValue = ko.computed(function() { return 'zzz' });
var vm = { prop: computedValue };

testNode.innerHTML = "<input data-bind='textInput: prop' />";
ko.applyBindings(vm, testNode);
expect(testNode.childNodes[0].value).toEqual("zzz");

// Change the input value and trigger change event; verify that the view model wasn't changed
testNode.childNodes[0].value = "yyy";
ko.utils.triggerEvent(testNode.childNodes[0], "change");
expect(vm.prop).toEqual(computedValue);
expect(computedValue()).toEqual('zzz');
});

it('For non-observable property values, should catch the node\'s onchange and write values back to the property', function () {
var model = { modelProperty123: 456 };
testNode.innerHTML = "<input data-bind='textInput: modelProperty123' />";
ko.applyBindings(model, testNode);
expect(testNode.childNodes[0].value).toEqual("456");

testNode.childNodes[0].value = 789;
ko.utils.triggerEvent(testNode.childNodes[0], "change");
expect(model.modelProperty123).toEqual("789");
});

it('Should support alias "textinput"', function () {
testNode.innerHTML = "<input data-bind='textinput:123' />";
ko.applyBindings(null, testNode);
expect(testNode.childNodes[0].value).toEqual("123");
});

it('Should write to non-observable property values using "textinput" alias', function () {
var model = { modelProperty123: 456 };
testNode.innerHTML = "<input data-bind='textinput: modelProperty123' />";
ko.applyBindings(model, testNode);
expect(testNode.childNodes[0].value).toEqual("456");

testNode.childNodes[0].value = 789;
ko.utils.triggerEvent(testNode.childNodes[0], "change");
expect(model.modelProperty123).toEqual("789");
});

it('Should be able to read and write to a property of an object returned by a function', function () {
var mySetter = { set: 666 };
var model = {
getSetter: function () {
return mySetter;
}
};
testNode.innerHTML =
"<input data-bind='textInput: getSetter().set' />" +
"<input data-bind='textInput: getSetter()[\"set\"]' />" +
"<input data-bind=\"textInput: getSetter()['set']\" />";
ko.applyBindings(model, testNode);
expect(testNode.childNodes[0].value).toEqual('666');
expect(testNode.childNodes[1].value).toEqual('666');
expect(testNode.childNodes[2].value).toEqual('666');

// .property
testNode.childNodes[0].value = 667;
ko.utils.triggerEvent(testNode.childNodes[0], "change");
expect(mySetter.set).toEqual('667');

// ["property"]
testNode.childNodes[1].value = 668;
ko.utils.triggerEvent(testNode.childNodes[1], "change");
expect(mySetter.set).toEqual('668');

// ['property']
testNode.childNodes[0].value = 669;
ko.utils.triggerEvent(testNode.childNodes[0], "change");
expect(mySetter.set).toEqual('669');
});

it('Should be able to write to observable subproperties of an observable, even after the parent observable has changed', function () {
// This spec represents https://github.com/SteveSanderson/knockout/issues#issue/13
var originalSubproperty = ko.observable("original value");
var newSubproperty = ko.observable();
var model = { myprop: ko.observable({ subproperty : originalSubproperty }) };

// Set up a text box whose value is linked to the subproperty of the observable's current value
testNode.innerHTML = "<input data-bind='textInput: myprop().subproperty' />";
ko.applyBindings(model, testNode);
expect(testNode.childNodes[0].value).toEqual("original value");

model.myprop({ subproperty : newSubproperty }); // Note that myprop (and hence its subproperty) is changed *after* the bindings are applied
testNode.childNodes[0].value = "Some new value";
ko.utils.triggerEvent(testNode.childNodes[0], "change");

// Verify that the change was written to the *new* subproperty, not the one referenced when the bindings were first established
expect(newSubproperty()).toEqual("Some new value");
expect(originalSubproperty()).toEqual("original value");
});

it('Should update observable on input event (on supported browsers) or propertychange event (on old IE)', function () {
var myobservable = new ko.observable(123);
testNode.innerHTML = "<input data-bind='textInput: someProp' />";
ko.applyBindings({ someProp: myobservable }, testNode);
expect(testNode.childNodes[0].value).toEqual("123");

testNode.childNodes[0].value = "some user-entered value"; // setting the value triggers the propertychange event on IE
if (!jasmine.ieVersion || jasmine.ieVersion >= 9) {
ko.utils.triggerEvent(testNode.childNodes[0], "input");
}
expect(myobservable()).toEqual("some user-entered value");
});

it('Should write only changed values to observable', function () {
var observable = ko.observable(), previousValue;
var valueForEditing = ko.computed({
read: observable,
write: function(newValue) {
expect(newValue).not.toEqual(previousValue);
previousValue = newValue;
observable(newValue);
}
});

testNode.innerHTML = "<input data-bind='textInput: valueForEditing' />";
ko.applyBindings({ valueForEditing: valueForEditing}, testNode);

testNode.childNodes[0].value = "1234";
ko.utils.triggerEvent(testNode.childNodes[0], "change");
expect(valueForEditing()).toEqual("1234");

// trigger change event with the same value
ko.utils.triggerEvent(testNode.childNodes[0], "change");
expect(valueForEditing()).toEqual("1234");
});
});
1 change: 1 addition & 0 deletions spec/runner.html
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
<script type="text/javascript" src="defaultBindings/styleBehaviors.js"></script>
<script type="text/javascript" src="defaultBindings/submitBehaviors.js"></script>
<script type="text/javascript" src="defaultBindings/textBehaviors.js"></script>
<script type="text/javascript" src="defaultBindings/textInputBehaviors.js"></script>
<script type="text/javascript" src="defaultBindings/uniqueNameBehaviors.js"></script>
<script type="text/javascript" src="defaultBindings/valueBehaviors.js"></script>
<script type="text/javascript" src="defaultBindings/visibleBehaviors.js"></script>
Expand Down
147 changes: 147 additions & 0 deletions src/binding/defaultBindings/textInput.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
(function () {

if (window) {
// Detect Opera version because Opera 10 doesn't fully support the input event
var operaVersion = window.opera && window.opera.version && parseInt(window.opera.version());

var safariVersion = window.navigator.userAgent.match(/^(?:(?!chrome).)*version\/(.*) safari/i);
if (safariVersion) {
safariVersion = parseInt(safariVersion[1]);
}
}

// IE 8 and 9 have bugs that prevent the normal events from firing when the value changes.
// But it does fires the selectionchange event on many of those, presumably because the
// cursor is moving and that counts as the selection changing. The selectionchange event is
// fired at the document level only and doesn't directly indicate which element changed. We
// set up just one event handler for the document and use activeElement to determine which
// element was changed.
if (ko.utils.ieVersion < 10) {
var selectionChangeRegisteredName = ko.utils.domData.nextKey(),
selectionChangeHandlerName = ko.utils.domData.nextKey();
var selectionChangeHandler = function(event) {
var target = this.activeElement,
handler = target && ko.utils.domData.get(target, selectionChangeHandlerName);
if (handler) {
handler(event);
}
};
var registerForSelectionChangeEvent = function (element, handler) {
var ownerDoc = element.ownerDocument;
if (!ko.utils.domData.get(ownerDoc, selectionChangeRegisteredName)) {
ko.utils.domData.set(ownerDoc, selectionChangeRegisteredName, true);
ko.utils.registerEventHandler(ownerDoc, 'selectionchange', selectionChangeHandler);
}
ko.utils.domData.set(element, selectionChangeHandlerName, handler);
};
}

ko.bindingHandlers['textInput'] = {
'init': function (element, valueAccessor, allBindings) {

var previousElementValue = element.value,
timeoutHandle,
elementValueBeforeEvent;

var updateModel = function () {
clearTimeout(timeoutHandle);
elementValueBeforeEvent = timeoutHandle = undefined;

var elementValue = element.value;
if (previousElementValue !== elementValue) {
previousElementValue = elementValue;
ko.expressionRewriting.writeValueToProperty(valueAccessor(), allBindings, 'textInput', elementValue);
}
};

var deferUpdateModel = function () {
if (!timeoutHandle) {
elementValueBeforeEvent = element.value;
timeoutHandle = setTimeout(updateModel, 4);
}
};

var updateView = function () {
var modelValue = ko.utils.unwrapObservable(valueAccessor());

if (modelValue === null || modelValue === undefined) {
modelValue = '';
}

if (elementValueBeforeEvent !== undefined && modelValue === elementValueBeforeEvent) {
setTimeout(updateView, 4);
return;
}

// Update the element only if the element and model are different. On some browsers, updating the value
// will move the cursor to the end of the input, which would be bad while the user is typing.
if (element.value !== modelValue) {
previousElementValue = modelValue; // Make sure we ignore events (propertychange) that result from updating the value

element.value = modelValue;
}
};

var onEvent = function (event, handler) {
ko.utils.registerEventHandler(element, event, handler);
};

if (ko.utils.ieVersion < 9) {
// Internet Explorer <=8 doesn't support the 'input' event, but does include 'propertychange' that fires whenever
// any property of an element changes. Unlike 'input', it also fires if a property is changed from JavaScript code,
// but that's an acceptable compromise for this binding.
onEvent('propertychange', function(event) {
if (event.propertyName === 'value') {
updateModel();
}
});

if (ko.utils.ieVersion == 8) {
// IE 8 has a bug where it fails to fire 'propertychange' on the first update following a value change from
// JavaScript code. To fix this, we bind to the following events also.
onEvent('keyup', updateModel); // A single keystoke
onEvent('keydown', updateModel); // The first character when a key is held down

registerForSelectionChangeEvent(element, updateModel); // 'selectionchange' covers cut, paste, drop, delete, etc.
onEvent('dragend', deferUpdateModel);
}
} else {
// All other supported browsers support the 'input' event, which fires whenver the content of element is changed
// through the user interface.
onEvent('input', updateModel);

if (ko.utils.ieVersion == 9) {
// Internet Explorer 9 doesn't fire the 'input' event when deleting text, including using
// the backspace, delete, or ctrl-x keys, clicking the 'x' to clear the input, dragging text
// out of the field, and cutting or deleting text using the context menu. 'selectionchange'
// can detect all of those except dragging text out of the field, for which we use 'dragend'.
registerForSelectionChangeEvent(element, updateModel);
onEvent('dragend', deferUpdateModel);
} else if (safariVersion < 5 && ko.utils.tagNameLower(element) === "textarea") {
// Safari <5 doesn't fire the 'input' event for <textarea> elements, but it does fire
// 'textInput'.
onEvent('textInput', updateModel);
} else if (operaVersion < 11) {
// Opera 10 doesn’t fire the 'input' event for cut, paste, undo & drop operations on <input>
// elements. We can try to catch some of those using 'keydown'.
onEvent('keydown', deferUpdateModel);
}
}

// Bind to the change event so that we can catch programmatic updates of the value that fire this event.
onEvent('change', updateModel);

ko.computed(updateView, null, { disposeWhenNodeIsRemoved: element });
}
};
ko.expressionRewriting.twoWayBindings['textInput'] = true;

// textinput is an alias textInput
ko.bindingHandlers['textinput'] = {
// preprocess is the only way to set up a full alias
preprocess: function (value, name, addBinding) {
addBinding('textInput', value);
}
};

})();

0 comments on commit 5c932e2

Please sign in to comment.