Repository: mozilla-services/react-jsonschema-form (js)

Issue:
onBlur event
### Prerequisites
- [X] I have read the [documentation](https://github.com/mozilla-services/react-jsonschema-form/blob/master/README.md#readme);
### Description

Just as we currently have an onChange event I'd love an onBlur, we have some forms that trigger validation or saving on field focus changes. Happy to write the PR if it's of interest.


Pull request description:
Add onBlur event
### Reasons for making this change

We want to be able to show validation errors only after a user has touched a field or tried to submit. We can do the submit part easily through the onError hook and formContext, but we need a way to track blur events. This adds that, similar to the onChange hook.

Should close #364 

### Checklist

* [X] **I'm updating documentation**
  - [X] I've checked the rendering of the Markdown text I've added
  - [X] If I'm adding a new section, I've updated the Table of Content
* [X] **I'm adding or updating code**
  - [X] I've added and/or updated tests
  - [X] I've updated docs if needed
* [X] **I'm adding a new feature**
  - [X] I've updated the playground with an example use of the feature


Patch:
diff --git a/README.md b/README.md
index 29b13c593..f4cb9bfc2 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@ A [live playground](https://mozilla-services.github.io/react-jsonschema-form/) i
         - [Form submission](#form-submission)
         - [Form error event handler](#form-error-event-handler)
         - [Form data changes](#form-data-changes)
+        - [Form field blur events](#form-field-blur-events)
   - [Form customization](#form-customization)
      - [The uiSchema object](#the-uischema-object)
      - [Alternative widgets](#alternative-widgets)
@@ -65,6 +66,7 @@ A [live playground](https://mozilla-services.github.io/react-jsonschema-form/) i
      - [Custom descriptions](#custom-descriptions)
   - [Form data validation](#form-data-validation)
      - [Live validation](#live-validation)
+     - [HTML5 validation](#html5-validation)
      - [Custom validation](#custom-validation)
      - [Custom error messages](#custom-error-messages)
      - [Error List Display](#error-list-display)
@@ -194,6 +196,10 @@ render((
 
 If you plan on being notified everytime the form data are updated, you can pass an `onChange` handler, which will receive the same args as `onSubmit` any time a value is updated in the form.
 
+#### Form field blur events
+
+Sometimes you may want to trigger events or modify external state when a field has been touched, so you can pass an `onBlur` handler, which will receive the id of the input that was blurred and the field value.
+
 ## Form customization
 
 ### The `uiSchema` object
@@ -812,12 +818,14 @@ render((
 
 The following props are passed to custom widget components:
 
+- `id`: The generated id for this field;
 - `schema`: The JSONSchema subschema object for this field;
 - `value`: The current value for this field;
 - `required`: The required status of this field;
 - `disabled`: `true` if the widget is disabled;
 - `readonly`: `true` if the widget is read-only;
 - `onChange`: The value change event handler; call it with the new value everytime it changes;
+- `onBlur`: The input blur event handler; call it with the the widget id and value;
 - `options`: A map of options passed as a prop to the component (see [Custom widget options](#custom-widget-options)).
 - `formContext`: The `formContext` object that you passed to Form.
 
@@ -1138,6 +1146,10 @@ Be warned that this is an expensive strategy, with possibly strong impact on per
 
 To disable validation entirely, you can set Form's `noValidate` prop to `true`.
 
+### HTML5 Validation
+
+By default, required field errors will cause the browser to display its standard HTML5 `required` attribute error messages and prevent form submission. If you would like to turn this off, you can set Form's `noHtml5Validate` prop to `true`, which will set `noValidate` on the `form` element.
+
 ### Custom validation
 
 Form data is always validated against the JSON schema.
diff --git a/playground/app.js b/playground/app.js
index 445e678f1..e3b25acab 100644
--- a/playground/app.js
+++ b/playground/app.js
@@ -364,6 +364,7 @@ class App extends Component {
               onChange={this.onFormDataChange}
               fields={{geo: GeoPosition}}
               validate={validate}
+              onBlur={(id, value) => console.log(`Touched ${id} with value ${value}`)}
               transformErrors={transformErrors}
               onError={log("errors")} />}
         </div>
diff --git a/src/components/Form.js b/src/components/Form.js
index de24535aa..7f8f2f31e 100644
--- a/src/components/Form.js
+++ b/src/components/Form.js
@@ -17,6 +17,7 @@ export default class Form extends Component {
     noValidate: false,
     liveValidate: false,
     safeRenderCompletion: false,
+    noHtml5Validate: false
   }
 
   constructor(props) {
@@ -88,6 +89,12 @@ export default class Form extends Component {
     });
   };
 
+  onBlur = (...args) => {
+    if (this.props.onBlur) {
+      this.props.onBlur(...args);
+    }
+  }
+
   onSubmit = (event) => {
     event.preventDefault();
     this.setState({status: "submitted"});
@@ -138,7 +145,8 @@ export default class Form extends Component {
       action,
       autocomplete,
       enctype,
-      acceptcharset
+      acceptcharset,
+      noHtml5Validate
     } = this.props;
 
     const {schema, uiSchema, formData, errorSchema, idSchema} = this.state;
@@ -155,6 +163,7 @@ export default class Form extends Component {
         autoComplete={autocomplete}
         encType={enctype}
         acceptCharset={acceptcharset}
+        noValidate={noHtml5Validate}
         onSubmit={this.onSubmit}>
         {this.renderErrors()}
         <_SchemaField
@@ -164,6 +173,7 @@ export default class Form extends Component {
           idSchema={idSchema}
           formData={formData}
           onChange={this.onChange}
+          onBlur={this.onBlur}
           registry={registry}
           safeRenderCompletion={safeRenderCompletion}/>
         { children ? children :
@@ -201,6 +211,7 @@ if (process.env.NODE_ENV !== "production") {
     enctype: PropTypes.string,
     acceptcharset: PropTypes.string,
     noValidate: PropTypes.bool,
+    noHtml5Validate: PropTypes.bool,
     liveValidate: PropTypes.bool,
     validate: PropTypes.func,
     transformErrors: PropTypes.func,
diff --git a/src/components/fields/ArrayField.js b/src/components/fields/ArrayField.js
index 987724d77..d610ef081 100644
--- a/src/components/fields/ArrayField.js
+++ b/src/components/fields/ArrayField.js
@@ -282,7 +282,8 @@ class ArrayField extends Component {
       disabled,
       readonly,
       autofocus,
-      registry
+      registry,
+      onBlur
     } = this.props;
     const title = (schema.title === undefined) ? name : schema.title;
     const {items = []} = this.state;
@@ -306,7 +307,8 @@ class ArrayField extends Component {
           itemErrorSchema,
           itemData: items[index],
           itemUiSchema: uiSchema.items,
-          autofocus: autofocus && index === 0
+          autofocus: autofocus && index === 0,
+          onBlur
         });
       }),
       className: `field field-array field-array-of-${itemsSchema.type}`,
@@ -327,7 +329,7 @@ class ArrayField extends Component {
   }
 
   renderMultiSelect() {
-    const {schema, idSchema, uiSchema, disabled, readonly, autofocus} = this.props;
+    const {schema, idSchema, uiSchema, disabled, readonly, autofocus, onBlur} = this.props;
     const {items} = this.state;
     const {widgets, definitions} = this.props.registry;
     const itemsSchema = retrieveSchema(schema.items, definitions);
@@ -339,6 +341,7 @@ class ArrayField extends Component {
         id={idSchema && idSchema.$id}
         multiple
         onChange={this.onSelectChange}
+        onBlur={onBlur}
         options={options}
         schema={schema}
         value={items}
@@ -349,7 +352,7 @@ class ArrayField extends Component {
   }
 
   renderFiles() {
-    const {schema, uiSchema, idSchema, name, disabled, readonly, autofocus} = this.props;
+    const {schema, uiSchema, idSchema, name, disabled, readonly, autofocus, onBlur} = this.props;
     const title = schema.title || name;
     const {items} = this.state;
     const {widgets} = this.props.registry;
@@ -361,6 +364,7 @@ class ArrayField extends Component {
         id={idSchema && idSchema.$id}
         multiple
         onChange={this.onSelectChange}
+        onBlur={onBlur}
         schema={schema}
         title={title}
         value={items}
@@ -381,7 +385,8 @@ class ArrayField extends Component {
       disabled,
       readonly,
       autofocus,
-      registry
+      registry,
+      onBlur
     } = this.props;
     const title = schema.title || name;
     let {items} = this.state;
@@ -428,7 +433,8 @@ class ArrayField extends Component {
           itemUiSchema,
           itemIdSchema,
           itemErrorSchema,
-          autofocus: autofocus && index === 0
+          autofocus: autofocus && index === 0,
+          onBlur
         });
       }),
       onAddClick: this.onAddClick,
@@ -454,7 +460,8 @@ class ArrayField extends Component {
     itemUiSchema,
     itemIdSchema,
     itemErrorSchema,
-    autofocus
+    autofocus,
+    onBlur
   }) {
     const {SchemaField} = this.props.registry.fields;
     const {disabled, readonly, uiSchema} = this.props;
@@ -480,6 +487,7 @@ class ArrayField extends Component {
           idSchema={itemIdSchema}
           required={this.isItemRequired(itemSchema)}
           onChange={this.onChangeForIndex(index)}
+          onBlur={onBlur}
           registry={this.props.registry}
           disabled={this.props.disabled}
           readonly={this.props.readonly}
@@ -524,6 +532,7 @@ if (process.env.NODE_ENV !== "production") {
     idSchema: PropTypes.object,
     errorSchema: PropTypes.object,
     onChange: PropTypes.func.isRequired,
+    onBlur: PropTypes.func,
     formData: PropTypes.array,
     required: PropTypes.bool,
     disabled: PropTypes.bool,
diff --git a/src/components/fields/ObjectField.js b/src/components/fields/ObjectField.js
index 17e3d506b..1512aa1db 100644
--- a/src/components/fields/ObjectField.js
+++ b/src/components/fields/ObjectField.js
@@ -91,7 +91,8 @@ class ObjectField extends Component {
       name,
       required,
       disabled,
-      readonly
+      readonly,
+      onBlur
     } = this.props;
     const {definitions, fields, formContext} = this.props.registry;
     const {SchemaField, TitleField, DescriptionField} = fields;
@@ -136,6 +137,7 @@ class ObjectField extends Component {
               idSchema={idSchema[name]}
               formData={this.state[name]}
               onChange={this.onPropertyChange(name)}
+              onBlur={onBlur}
               registry={this.props.registry}
               disabled={disabled}
               readonly={readonly}/>
diff --git a/src/components/fields/StringField.js b/src/components/fields/StringField.js
index f1ed3eb0f..98df908c9 100644
--- a/src/components/fields/StringField.js
+++ b/src/components/fields/StringField.js
@@ -21,7 +21,8 @@ function StringField(props) {
     readonly,
     autofocus,
     registry,
-    onChange
+    onChange,
+    onBlur
   } = props;
   const {title, format} = schema;
   const {widgets, formContext} = registry;
@@ -37,6 +38,7 @@ function StringField(props) {
     label={title === undefined ? name : title}
     value={defaultFieldValue(formData, schema)}
     onChange={onChange}
+    onBlur={onBlur}
     required={required}
     disabled={disabled}
     readonly={readonly}
@@ -52,6 +54,7 @@ if (process.env.NODE_ENV !== "production") {
     uiSchema: PropTypes.object.isRequired,
     idSchema: PropTypes.object,
     onChange: PropTypes.func.isRequired,
+    onBlur: PropTypes.func.isRequired,
     formData: PropTypes.oneOfType([
       React.PropTypes.string,
       React.PropTypes.number,
diff --git a/src/components/widgets/AltDateWidget.js b/src/components/widgets/AltDateWidget.js
index e228b7132..ddbcfa90c 100644
--- a/src/components/widgets/AltDateWidget.js
+++ b/src/components/widgets/AltDateWidget.js
@@ -16,7 +16,7 @@ function readyForChange(state) {
 }
 
 function DateElement(props) {
-  const {type, range, value, select, rootId, disabled, readonly, autofocus, registry} = props;
+  const {type, range, value, select, rootId, disabled, readonly, autofocus, registry, onBlur} = props;
   const id = rootId + "_" + type;
   const {SelectWidget} = registry.widgets;
   return (
@@ -29,7 +29,8 @@ function DateElement(props) {
       disabled={disabled}
       readonly={readonly}
       autofocus={autofocus}
-      onChange={(value) => select(type, value)}/>
+      onChange={(value) => select(type, value)}
+      onBlur={onBlur}/>
   );
 }
 
@@ -101,7 +102,7 @@ class AltDateWidget extends Component {
   }
 
   render() {
-    const {id, disabled, readonly, autofocus, registry} = this.props;
+    const {id, disabled, readonly, autofocus, registry, onBlur} = this.props;
     return (
       <ul className="list-inline">{
         this.dateElementProps.map((elemProps, i) => (
@@ -113,6 +114,7 @@ class AltDateWidget extends Component {
               disabled= {disabled}
               readonly={readonly}
               registry={registry}
+              onBlur={onBlur}
               autofocus={autofocus && i === 0}/>
           </li>
         ))
@@ -140,6 +142,7 @@ if (process.env.NODE_ENV !== "production") {
     readonly: PropTypes.bool,
     autofocus: PropTypes.bool,
     onChange: PropTypes.func,
+    onBlur: PropTypes.func,
     time: PropTypes.bool,
   };
 }
diff --git a/src/components/widgets/BaseInput.js b/src/components/widgets/BaseInput.js
index 32d59362e..d07ba5021 100644
--- a/src/components/widgets/BaseInput.js
+++ b/src/components/widgets/BaseInput.js
@@ -8,6 +8,7 @@ function BaseInput(props) {
     value,
     readonly,
     autofocus,
+    onBlur,
     options,  // eslint-disable-line
     schema,   // eslint-disable-line
     formContext,  // eslint-disable-line
@@ -24,7 +25,8 @@ function BaseInput(props) {
       readOnly={readonly}
       autoFocus={autofocus}
       value={typeof value === "undefined" ? "" : value}
-      onChange={_onChange} />
+      onChange={_onChange}
+      onBlur={onBlur && (event => onBlur(inputProps.id, event.target.value))}/>
   );
 }
 
@@ -46,6 +48,7 @@ if (process.env.NODE_ENV !== "production") {
     readonly: PropTypes.bool,
     autofocus: PropTypes.bool,
     onChange: PropTypes.func,
+    onBlur: PropTypes.func
   };
 }
 
diff --git a/src/components/widgets/SelectWidget.js b/src/components/widgets/SelectWidget.js
index 32f0545d5..41cc8ca66 100644
--- a/src/components/widgets/SelectWidget.js
+++ b/src/components/widgets/SelectWidget.js
@@ -2,7 +2,6 @@ import React, {PropTypes} from "react";
 
 import {asNumber} from "../../utils";
 
-
 /**
  * This is a silly limitation in the DOM where option change event values are
  * always retrieved as strings.
@@ -18,6 +17,16 @@ function processValue({type, items}, value) {
   return value;
 }
 
+function getValue(event, multiple) {
+  if (multiple) {
+    return [].slice.call(event.target.options)
+      .filter(o => o.selected)
+      .map(o => o.value);
+  } else {
+    return event.target.value;
+  }
+}
+
 function SelectWidget({
   schema,
   id,
@@ -28,7 +37,8 @@ function SelectWidget({
   readonly,
   multiple,
   autofocus,
-  onChange
+  onChange,
+  onBlur
 }) {
   const {enumOptions} = options;
   return (
@@ -41,14 +51,12 @@ function SelectWidget({
       disabled={disabled}
       readOnly={readonly}
       autoFocus={autofocus}
+      onBlur={onBlur && (event => {
+        const newValue = getValue(event, multiple);
+        onBlur(id, processValue(schema, newValue));
+      })}
       onChange={(event) => {
-        let newValue;
-        if (multiple) {
-          newValue = [].filter.call(
-            event.target.options, o => o.selected).map(o => o.value);
-        } else {
-          newValue = event.target.value;
-        }
+        const newValue = getValue(event, multiple);
         onChange(processValue(schema, newValue));
       }}>{
       enumOptions.map(({value, label}, i) => {
@@ -74,6 +82,7 @@ if (process.env.NODE_ENV !== "production") {
     multiple: PropTypes.bool,
     autofocus: PropTypes.bool,
     onChange: PropTypes.func,
+    onBlur: PropTypes.func,
   };
 }
 
diff --git a/src/components/widgets/TextareaWidget.js b/src/components/widgets/TextareaWidget.js
index 38d665811..70aa3d9f4 100644
--- a/src/components/widgets/TextareaWidget.js
+++ b/src/components/widgets/TextareaWidget.js
@@ -10,7 +10,8 @@ function TextareaWidget({
   disabled,
   readonly,
   autofocus,
-  onChange
+  onChange,
+  onBlur
 }) {
   const _onChange = ({target: {value}}) => {
     return onChange(value === "" ? undefined : value);
@@ -25,6 +26,7 @@ function TextareaWidget({
       disabled={disabled}
       readOnly={readonly}
       autoFocus={autofocus}
+      onBlur={onBlur && (event => onBlur(id, event.target.value))}
       onChange={_onChange} />
   );
 }
@@ -42,6 +44,7 @@ if (process.env.NODE_ENV !== "production") {
     required: PropTypes.bool,
     autofocus: PropTypes.bool,
     onChange: PropTypes.func,
+    onBlur: PropTypes.func,
   };
 }
 


Test patch:
diff --git a/test/ArrayField_test.js b/test/ArrayField_test.js
index f4febee80..c71b49547 100644
--- a/test/ArrayField_test.js
+++ b/test/ArrayField_test.js
@@ -322,6 +322,22 @@ describe("ArrayField", () => {
         expect(comp.state.formData).eql(["foo", "bar"]);
       });
 
+      it("should handle a blur event", () => {
+        const onBlur = sandbox.spy();
+        const {node} = createFormComponent({schema, onBlur});
+
+        const select = node.querySelector(".field select");
+        Simulate.blur(select, {
+          target: {options: [
+            {selected: true, value: "foo"},
+            {selected: true, value: "bar"},
+            {selected: false, value: "fuzz"},
+          ]}
+        });
+
+        expect(onBlur.calledWith(select.id, ["foo", "bar"])).to.be.true;
+      });
+
       it("should fill field with data", () => {
         const {node} = createFormComponent({schema, formData: ["foo", "bar"]});
 
diff --git a/test/Form_test.js b/test/Form_test.js
index be0c0ce02..10bb712c7 100644
--- a/test/Form_test.js
+++ b/test/Form_test.js
@@ -503,6 +503,30 @@ describe("Form", () => {
       });
     });
   });
+  describe("Blur handler", () => {
+    it("should call provided blur handler on form input blur event", () => {
+      const schema = {
+        type: "object",
+        properties: {
+          foo: {
+            type: "string",
+          },
+        }
+      };
+      const formData = {
+        foo: ""
+      };
+      const onBlur = sandbox.spy();
+      const {node} = createFormComponent({schema, formData, onBlur});
+
+      const input = node.querySelector("[type=text]");
+      Simulate.blur(input, {
+        target: {value: "new"}
+      });
+
+      sinon.assert.calledWithMatch(onBlur, input.id, "new");
+    });
+  });
 
   describe("Error handler", () => {
     it("should call provided error handler on validation errors", () => {
@@ -1131,7 +1155,8 @@ describe("Form", () => {
       action: "/users/list",
       autocomplete: "off",
       enctype: "multipart/form-data",
-      acceptcharset: "ISO-8859-1"
+      acceptcharset: "ISO-8859-1",
+      noHtml5Validate: true
     };
 
     let node;
@@ -1175,5 +1200,9 @@ describe("Form", () => {
     it("should set attr acceptcharset of form", () => {
       expect(node.getAttribute("accept-charset")).eql(formProps.acceptcharset);
     });
+
+    it("should set attr novalidate of form", () => {
+      expect(node.getAttribute("novalidate")).not.to.be.null;
+    });
   });
 });
diff --git a/test/NumberField_test.js b/test/NumberField_test.js
index f7081bf76..80b468c10 100644
--- a/test/NumberField_test.js
+++ b/test/NumberField_test.js
@@ -74,6 +74,19 @@ describe("NumberField", () => {
       expect(comp.state.formData).eql(2);
     });
 
+    it("should handle a blur event", () => {
+      const onBlur = sandbox.spy();
+      const {node} = createFormComponent({schema: {
+        type: "number",
+      }, onBlur});
+
+      const input = node.querySelector("input");
+      Simulate.blur(input, {
+        target: {value: "2"}
+      });
+
+      expect(onBlur.calledWith(input.id, 2));
+    });
     it("should fill field with data", () => {
       const {node} = createFormComponent({schema: {
         type: "number",
diff --git a/test/ObjectField_test.js b/test/ObjectField_test.js
index 40f3a88f7..18e86a5e8 100644
--- a/test/ObjectField_test.js
+++ b/test/ObjectField_test.js
@@ -133,6 +133,18 @@ describe("ObjectField", () => {
       expect(comp.state.formData.foo).eql("changed");
     });
 
+    it("should handle object fields with blur events", () => {
+      const onBlur = sandbox.spy();
+      const {node} = createFormComponent({schema, onBlur});
+
+      const input = node.querySelector("input[type=text]");
+      Simulate.blur(input, {
+        target: {value: "changed"}
+      });
+
+      expect(onBlur.calledWith(input.id, "changed")).to.be.true;
+    });
+
     it("should render the widget with the expected id", () => {
       const {node} = createFormComponent({schema});
 
diff --git a/test/StringField_test.js b/test/StringField_test.js
index f76a852f1..1dac26040 100644
--- a/test/StringField_test.js
+++ b/test/StringField_test.js
@@ -77,6 +77,19 @@ describe("StringField", () => {
       expect(comp.state.formData).eql("yo");
     });
 
+    it("should handle a blur event", () => {
+      const onBlur = sandbox.spy();
+      const {node} = createFormComponent({schema: {
+        type: "string",
+      }, onBlur});
+      const input = node.querySelector("input");
+      Simulate.blur(input, {
+        target: {value: "yo"}
+      });
+
+      expect(onBlur.calledWith(input.id, "yo")).to.be.true;
+    });
+    
     it("should handle an empty string change event", () => {
       const {comp, node} = createFormComponent({
         schema: {type: "string"},
diff --git a/test/performance_test.js b/test/performance_test.js
index 098230bd1..481698217 100644
--- a/test/performance_test.js
+++ b/test/performance_test.js
@@ -50,6 +50,7 @@ describe("Rendering performance optimizations", () => {
 
   describe("ArrayField", () => {
     const onChange = () => {};
+    const onBlur = () => {};
     const schema = {type: "array", items: {type: "string"}};
     const uiSchema = {};
     const registry = getDefaultRegistry();
@@ -60,6 +61,7 @@ describe("Rendering performance optimizations", () => {
         schema,
         uiSchema,
         onChange,
+        onBlur
       });
       sandbox.stub(comp, "render").returns(<div/>);
 
@@ -76,6 +78,7 @@ describe("Rendering performance optimizations", () => {
         schema,
         formData,
         onChange,
+        onBlur
       });
       sandbox.stub(comp, "render").returns(<div/>);
 
@@ -87,6 +90,7 @@ describe("Rendering performance optimizations", () => {
 
   describe("ObjectField", () => {
     const onChange = () => {};
+    const onBlur = () => {};
     const registry = getDefaultRegistry();
     const uiSchema = {};
     const schema = {
@@ -104,6 +108,7 @@ describe("Rendering performance optimizations", () => {
         uiSchema,
         onChange,
         idSchema,
+        onBlur
       });
       sandbox.stub(comp, "render").returns(<div/>);
 
@@ -121,6 +126,7 @@ describe("Rendering performance optimizations", () => {
         formData,
         onChange,
         idSchema,
+        onBlur
       });
       sandbox.stub(comp, "render").returns(<div/>);