Skip to content

Support Indeterminate checkboxes #5908

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/docs/07-forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Form components support a few props that are affected via user interactions:

* `value`, supported by `<input>` and `<textarea>` components.
* `checked`, supported by `<input>` components of type `checkbox` or `radio`.
* `indeterminate`, supported by `<input>` components of type `checkbox`.
* `selected`, supported by `<option>` components.

In HTML, the value of `<textarea>` is set via children. In React, you should use `value` instead.
Expand Down Expand Up @@ -101,6 +102,11 @@ Likewise, `<input type="checkbox">` and `<input type="radio">` support `defaultC
>
> The `defaultValue` and `defaultChecked` props are only used during initial render. If you need to update the value in a subsequent render, you will need to use a [controlled component](#controlled-components).

In addition to `checked`, `<input type="checkbox">` elements also support `indeterminate` and `defaultIndeterminate` props for simulating "tri-state" checkboxes. Like the the native property, the indeterminate value is _distinct_ from
the `checked` value, but also toggled along with `checked` value changes. Use `defaultIndeterminate` in
the same way as `defaultChecked` to set an initial value, and `indeterminate` in combination with `onChange` to
explicitly control the indeterminate state.

## Advanced Topics

### Why Controlled Components?
Expand Down
59 changes: 59 additions & 0 deletions src/renderers/dom/client/wrappers/ReactDOMInput.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ var didWarnCheckedLink = false;
var didWarnValueNull = false;
var didWarnValueDefaultValue = false;
var didWarnCheckedDefaultChecked = false;
var didWarnIndeterminateDefaultIndeterminate = false;
var didWarnMismatchedType = false;

function forceUpdateIfMounted() {
if (this._rootNodeID) {
Expand Down Expand Up @@ -66,6 +68,7 @@ var ReactDOMInput = {
getNativeProps: function(inst, props) {
var value = LinkedValueUtils.getValue(props);
var checked = LinkedValueUtils.getChecked(props);
var indeterminate = props.indeterminate;

var nativeProps = assign({
// Make sure we set .type before any other properties (setting .value
Expand All @@ -74,8 +77,10 @@ var ReactDOMInput = {
}, props, {
defaultChecked: undefined,
defaultValue: undefined,
defaultIndeterminate: undefined,
value: value != null ? value : inst._wrapperState.initialValue,
checked: checked != null ? checked : inst._wrapperState.initialChecked,
indeterminate: indeterminate != null ? indeterminate : inst._wrapperState.initialIndeterminate,
onChange: inst._wrapperState.onChange,
});

Expand Down Expand Up @@ -119,6 +124,35 @@ var ReactDOMInput = {
);
didWarnCheckedDefaultChecked = true;
}
if (
(props.indeterminate !== undefined ||
props.defaultIndeterminate !== undefined) &&
props.type !== 'checkbox' &&
!didWarnMismatchedType
) {
warning(
false,
'Only input elements of the type "checkbox" can have an ' +
'indeterminate or defaultIndeterminate prop. Either change the ' +
'type prop or remove the indeterminate prop.'
);
didWarnMismatchedType = true;
}
if (
props.indeterminate !== undefined &&
props.defaultIndeterminate !== undefined &&
!didWarnIndeterminateDefaultIndeterminate
) {
warning(
false,
'Input elements must be either controlled or uncontrolled ' +
'(specify either the indeterminate prop, or the defaultIndeterminate prop, but not ' +
'both). Decide between using a controlled or uncontrolled input ' +
'element and remove one of these props. More info: ' +
'https://fb.me/react-controlled-components'
);
didWarnIndeterminateDefaultIndeterminate = true;
}
if (
props.value !== undefined &&
props.defaultValue !== undefined &&
Expand All @@ -140,6 +174,7 @@ var ReactDOMInput = {
var defaultValue = props.defaultValue;
inst._wrapperState = {
initialChecked: props.defaultChecked || false,
initialIndeterminate: props.defaultIndeterminate || false,
initialValue: defaultValue != null ? defaultValue : null,
listeners: null,
onChange: _handleChange.bind(inst),
Expand Down Expand Up @@ -173,7 +208,31 @@ var ReactDOMInput = {
'' + value
);
}

if (props.type === 'checkbox') {
var indeterminate = props.indeterminate;
if (indeterminate != null) {
DOMPropertyOperations.setValueForProperty(
ReactDOMComponentTree.getNodeFromInstance(inst),
'indeterminate',
indeterminate || false
);
}
}
},

initializeIndeterminate() {
var props = ReactDOMInput.getNativeProps(this, this._currentElement.props);

if (props.type === 'checkbox' && props.indeterminate) {
DOMPropertyOperations.setValueForProperty(
ReactDOMComponentTree.getNodeFromInstance(this),
'indeterminate',
props.indeterminate
);
}
},

};

function _handleChange(event) {
Expand Down
108 changes: 108 additions & 0 deletions src/renderers/dom/client/wrappers/__tests__/ReactDOMInput-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe('ReactDOMInput', function() {
var EventConstants;
var React;
var ReactDOM;
var ReactDOMServer;
var ReactLink;
var ReactTestUtils;

Expand All @@ -26,6 +27,7 @@ describe('ReactDOMInput', function() {
EventConstants = require('EventConstants');
React = require('React');
ReactDOM = require('ReactDOM');
ReactDOMServer = require('ReactDOMServer');
ReactLink = require('ReactLink');
ReactTestUtils = require('ReactTestUtils');
spyOn(console, 'error');
Expand Down Expand Up @@ -451,6 +453,112 @@ describe('ReactDOMInput', function() {
expect(console.error.argsForCall.length).toBe(1);
});

it('should warn when indeterminate is set on other input types', function() {
ReactTestUtils.renderIntoDocument(
<input type="text" indeterminate={true} />
);

expect(console.error.argsForCall[0][0]).toContain(
'Only input elements of the type "checkbox" can have an ' +
'indeterminate or defaultIndeterminate prop. Either change the ' +
'type prop or remove the indeterminate prop.'
);

ReactTestUtils.renderIntoDocument(
<input type="radio" defaultIndeterminate={true} />
);

expect(console.error.argsForCall.length).toBe(1);
});

it('should warn if indeterminate and defaultIndeterminate are specified', function() {
ReactTestUtils.renderIntoDocument(
<input type="checkbox" indeterminate={true} defaultIndeterminate={true}/>
);

expect(console.error.argsForCall[0][0]).toContain(
'Input elements must be either controlled or uncontrolled ' +
'(specify either the indeterminate prop, or the defaultIndeterminate prop, but not ' +
'both). Decide between using a controlled or uncontrolled input ' +
'element and remove one of these props. More info: ' +
'https://fb.me/react-controlled-components'
);

ReactTestUtils.renderIntoDocument(
<input type="checkbox" indeterminate={false} defaultIndeterminate={true}/>
);

expect(console.error.argsForCall.length).toBe(1);
});

it('should properly initialize indeterminate state', function() {
var stub = <input type="checkbox" indeterminate={true} onChange={emptyFunction} />;
stub = ReactTestUtils.renderIntoDocument(stub);
var node = ReactDOM.findDOMNode(stub);

expect(node.indeterminate).toBe(true);
});

it('should properly initialize defaultIndeterminate', function() {
var stub = <input type="checkbox" defaultIndeterminate={true} />;
stub = ReactTestUtils.renderIntoDocument(stub);
var node = ReactDOM.findDOMNode(stub);

expect(node.indeterminate).toBe(true);
});

it('should not set indeterminate attribute', function() {
var stub = <input type="checkbox" indeterminate={true} onChange={emptyFunction} />;
stub = ReactTestUtils.renderIntoDocument(stub);
var node = ReactDOM.findDOMNode(stub);

expect(node.hasAttribute('indeterminate')).toBe(false);
});

it('should properly control indeterminate', function() {
var stub = <input type="checkbox" indeterminate={true} onChange={emptyFunction} />;
stub = ReactTestUtils.renderIntoDocument(stub);
var node = ReactDOM.findDOMNode(stub);

node.indeterminate = false;
ReactTestUtils.Simulate.change(node);
expect(node.indeterminate).toBe(true);
});

it('should properly leave indeterminate uncontrolled', function() {
var stub = <input type="checkbox" defaultIndeterminate={true} />;
stub = ReactTestUtils.renderIntoDocument(stub);
var node = ReactDOM.findDOMNode(stub);

node.indeterminate = false;
ReactTestUtils.Simulate.change(node);
expect(node.indeterminate).toBe(false);
});

it('should properly set indeterminate from existing markup', function() {
var mount = document.createElement('div');
var stub = <input type="checkbox" indeterminate={true} />;

mount.innerHTML = ReactDOMServer.renderToString(stub);
stub = ReactDOM.render(stub, mount);

var node = ReactDOM.findDOMNode(stub);

expect(node.indeterminate).toBe(true);
});

it.only('should properly set defaultIndeterminate from existing markup', function() {
var mount = document.createElement('div');
var stub = <input type="checkbox" defaultIndeterminate={true} />;

mount.innerHTML = ReactDOMServer.renderToString(stub);
stub = ReactDOM.render(stub, mount);

var node = ReactDOM.findDOMNode(stub);

expect(node.indeterminate).toBe(true);
});

it('sets type before value always', function() {
var log = [];
var originalCreateElement = document.createElement;
Expand Down
8 changes: 8 additions & 0 deletions src/renderers/dom/shared/DOMProperty.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ var DOMPropertyInjection = {
HAS_NUMERIC_VALUE: 0x10,
HAS_POSITIVE_NUMERIC_VALUE: 0x20 | 0x10,
HAS_OVERLOADED_BOOLEAN_VALUE: 0x40,
HAS_IDL_ATTRIBUTE_ONLY: 0x80,

/**
* Inject some specialized knowledge about the DOM. This takes a config object
Expand Down Expand Up @@ -100,6 +101,8 @@ var DOMPropertyInjection = {
checkMask(propConfig, Injection.HAS_POSITIVE_NUMERIC_VALUE),
hasOverloadedBooleanValue:
checkMask(propConfig, Injection.HAS_OVERLOADED_BOOLEAN_VALUE),
hasIdlAttributeOnly:
checkMask(propConfig, Injection.HAS_IDL_ATTRIBUTE_ONLY),
};

invariant(
Expand All @@ -112,6 +115,11 @@ var DOMPropertyInjection = {
'DOMProperty: Properties that have side effects must use property: %s',
propName
);
invariant(
!propertyInfo.mustUseAttribute || !propertyInfo.hasIdlAttributeOnly,
'DOMProperty: Cannot require using the attribute and be IDL only: %s',
propName
);
invariant(
propertyInfo.hasBooleanValue + propertyInfo.hasNumericValue +
propertyInfo.hasOverloadedBooleanValue <= 1,
Expand Down
3 changes: 2 additions & 1 deletion src/renderers/dom/shared/DOMPropertyOperations.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ var DOMPropertyOperations = {
var propertyInfo = DOMProperty.properties.hasOwnProperty(name) ?
DOMProperty.properties[name] : null;
if (propertyInfo) {
if (shouldIgnoreValue(propertyInfo, value)) {
if (shouldIgnoreValue(propertyInfo, value) ||
propertyInfo.hasIdlAttributeOnly === true) {
return '';
}
var attributeName = propertyInfo.attributeName;
Expand Down
3 changes: 3 additions & 0 deletions src/renderers/dom/shared/HTMLDOMPropertyConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ var HAS_POSITIVE_NUMERIC_VALUE =
DOMProperty.injection.HAS_POSITIVE_NUMERIC_VALUE;
var HAS_OVERLOADED_BOOLEAN_VALUE =
DOMProperty.injection.HAS_OVERLOADED_BOOLEAN_VALUE;
var HAS_IDL_ATTRIBUTE_ONLY =
DOMProperty.injection.HAS_IDL_ATTRIBUTE_ONLY;

var hasSVG;
if (ExecutionEnvironment.canUseDOM) {
Expand Down Expand Up @@ -105,6 +107,7 @@ var HTMLDOMPropertyConfig = {
icon: null,
id: MUST_USE_PROPERTY,
inputMode: MUST_USE_ATTRIBUTE,
indeterminate: MUST_USE_PROPERTY | HAS_BOOLEAN_VALUE | HAS_IDL_ATTRIBUTE_ONLY,
integrity: null,
is: MUST_USE_ATTRIBUTE,
keyParams: MUST_USE_ATTRIBUTE,
Expand Down
1 change: 1 addition & 0 deletions src/renderers/dom/shared/ReactDOMComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,7 @@ ReactDOMComponent.Mixin = {
ReactDOMInput.mountWrapper(this, props, nativeParent);
props = ReactDOMInput.getNativeProps(this, props);
transaction.getReactMountReady().enqueue(trapBubbledEventsLocal, this);
transaction.getReactMountReady().enqueue(ReactDOMInput.initializeIndeterminate, this);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could be theoretically handled more generally, by having something loop through props propertyInfo and checking for IDL only attributes and setting them. Perhaps that would be more efficient? My assumption is that the above will properly batch everything anyway so it may not matter.

break;
case 'option':
ReactDOMOption.mountWrapper(this, props, nativeParent);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@ describe('DOMPropertyOperations', function() {
)).toBe('size="1"');
});

it('should create markup for IDL only attributes', function() {
expect(DOMPropertyOperations.createMarkupForProperty(
'indeterminate',
true
)).toBe('');
});
});

describe('createMarkupForProperty', function() {
Expand Down