diff --git a/docs/src/pages/css-in-js/basics/NestedStylesHook.js b/docs/src/pages/css-in-js/basics/NestedStylesHook.js new file mode 100644 index 00000000000000..d5cdfd38295fae --- /dev/null +++ b/docs/src/pages/css-in-js/basics/NestedStylesHook.js @@ -0,0 +1,29 @@ +import React from 'react'; +import { makeStyles } from '@material-ui/styles'; +import Paper from '@material-ui/core/Paper'; + +const useStyles = makeStyles({ + root: { + padding: 16, + color: 'red', + '& p': { + color: 'green', + '& span': { + color: 'blue', + }, + }, + }, +}); + +export default function NestedStylesHook() { + const classes = useStyles(); + return ( + + This is red since it is inside the paper. +

+ This is green since it is inside the paragraph{' '} + and this is blue since it is inside the span +

+
+ ); +} diff --git a/docs/src/pages/css-in-js/basics/NestedStylesHook.tsx b/docs/src/pages/css-in-js/basics/NestedStylesHook.tsx new file mode 100644 index 00000000000000..d5cdfd38295fae --- /dev/null +++ b/docs/src/pages/css-in-js/basics/NestedStylesHook.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { makeStyles } from '@material-ui/styles'; +import Paper from '@material-ui/core/Paper'; + +const useStyles = makeStyles({ + root: { + padding: 16, + color: 'red', + '& p': { + color: 'green', + '& span': { + color: 'blue', + }, + }, + }, +}); + +export default function NestedStylesHook() { + const classes = useStyles(); + return ( + + This is red since it is inside the paper. +

+ This is green since it is inside the paragraph{' '} + and this is blue since it is inside the span +

+
+ ); +} diff --git a/docs/src/pages/css-in-js/basics/basics.md b/docs/src/pages/css-in-js/basics/basics.md index 91281725894427..0b3f48f0cf487a 100644 --- a/docs/src/pages/css-in-js/basics/basics.md +++ b/docs/src/pages/css-in-js/basics/basics.md @@ -126,6 +126,28 @@ export default withStyles(styles)(HigherOrderComponent); {{"demo": "pages/css-in-js/basics/HigherOrderComponent.js"}} +## Nesting selectors + +You can nest selectors to target elements inside the current class or component. +The following example is powered by the Hook API, it works the same way with the other APIs. + +```js +const useStyles = makeStyles({ + root: { + padding: 16, + color: 'red', + '& p': { + color: 'green', + '& span': { + color: 'blue' + } + } + }, +}); +``` + +{{"demo": "pages/css-in-js/basics/NestedStylesHook.js"}} + ## Adapting based on props You can pass a function ("interpolations") to a style property to adapt it based on its props. diff --git a/docs/src/pages/demos/bottom-navigation/LabelBottomNavigation.tsx b/docs/src/pages/demos/bottom-navigation/LabelBottomNavigation.tsx new file mode 100644 index 00000000000000..58de02f68ccef4 --- /dev/null +++ b/docs/src/pages/demos/bottom-navigation/LabelBottomNavigation.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'; +import BottomNavigation from '@material-ui/core/BottomNavigation'; +import BottomNavigationAction from '@material-ui/core/BottomNavigationAction'; +import Icon from '@material-ui/core/Icon'; +import RestoreIcon from '@material-ui/icons/Restore'; +import FavoriteIcon from '@material-ui/icons/Favorite'; +import LocationOnIcon from '@material-ui/icons/LocationOn'; + +const useStyles = makeStyles({ + root: { + width: 500, + }, +}); + +function LabelBottomNavigation() { + const classes = useStyles(); + const [value, setValue] = React.useState('recents'); + + function handleChange(event: React.ChangeEvent<{}>, newValue: string) { + setValue(newValue); + } + + return ( + + } /> + } /> + } /> + folder} /> + + ); +} + +export default LabelBottomNavigation; diff --git a/docs/src/pages/demos/bottom-navigation/SimpleBottomNavigation.tsx b/docs/src/pages/demos/bottom-navigation/SimpleBottomNavigation.tsx new file mode 100644 index 00000000000000..3a1935c9443754 --- /dev/null +++ b/docs/src/pages/demos/bottom-navigation/SimpleBottomNavigation.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'; +import BottomNavigation from '@material-ui/core/BottomNavigation'; +import BottomNavigationAction from '@material-ui/core/BottomNavigationAction'; +import RestoreIcon from '@material-ui/icons/Restore'; +import FavoriteIcon from '@material-ui/icons/Favorite'; +import LocationOnIcon from '@material-ui/icons/LocationOn'; + +const useStyles = makeStyles({ + root: { + width: 500, + }, +}); + +function SimpleBottomNavigation() { + const classes = useStyles(); + const [value, setValue] = React.useState(0); + + return ( + { + setValue(newValue); + }} + showLabels + className={classes.root} + > + } /> + } /> + } /> + + ); +} + +export default SimpleBottomNavigation; diff --git a/docs/src/pages/utils/popper/popper.md b/docs/src/pages/utils/popper/popper.md index 3678c293772890..999bdd961fdea0 100644 --- a/docs/src/pages/utils/popper/popper.md +++ b/docs/src/pages/utils/popper/popper.md @@ -14,8 +14,10 @@ Some important features of the `Popper` component: - 📦 Less than [10 KB gzipped](/size-snapshot). - The children is [`Portal`](/utils/portal/) to the body of the document to avoid rendering problems. You can disable this behavior with `disablePortal`. -- The scroll and click away aren't blocked like with the [`Popover`](/utils/popover/) component. +- The scroll isn't blocked like with the [`Popover`](/utils/popover/) component. The placement of the popper updates with the available area in the viewport. +- Clicking away does not hide the `Popper` component. + If you need this behavior, you can use [`ClickAwayListener`](utils/click-away-listener/) - see the example in the [menu documentation section](/demos/menus/#menulist-composition). - The `anchorEl` is passed as the reference object to create a new `Popper.js` instance. ## Simple Popper diff --git a/packages/material-ui/src/FilledInput/FilledInput.js b/packages/material-ui/src/FilledInput/FilledInput.js index a97f7236e91b8a..24429cb4da4fe4 100644 --- a/packages/material-ui/src/FilledInput/FilledInput.js +++ b/packages/material-ui/src/FilledInput/FilledInput.js @@ -97,7 +97,6 @@ export const styles = theme => { /* Styles applied to the root element if `multiline={true}`. */ multiline: { padding: '27px 12px 10px', - boxSizing: 'border-box', // Prevent padding issue with fullWidth. }, /* Styles applied to the `input` element. */ input: { diff --git a/packages/material-ui/src/InputBase/InputBase.js b/packages/material-ui/src/InputBase/InputBase.js index b17880153d3e4c..a8880796cf0014 100644 --- a/packages/material-ui/src/InputBase/InputBase.js +++ b/packages/material-ui/src/InputBase/InputBase.js @@ -37,6 +37,7 @@ export const styles = theme => { color: theme.palette.text.primary, fontSize: theme.typography.pxToRem(16), lineHeight: '1.1875em', // Reset (19px), match the native input line-height + boxSizing: 'border-box', // Prevent padding issue with fullWidth. cursor: 'text', display: 'inline-flex', alignItems: 'center', diff --git a/packages/material-ui/src/OutlinedInput/OutlinedInput.js b/packages/material-ui/src/OutlinedInput/OutlinedInput.js index 1395144e1e0602..e140cef208ba60 100644 --- a/packages/material-ui/src/OutlinedInput/OutlinedInput.js +++ b/packages/material-ui/src/OutlinedInput/OutlinedInput.js @@ -53,7 +53,6 @@ export const styles = theme => { /* Styles applied to the root element if `multiline={true}`. */ multiline: { padding: '18.5px 14px', - boxSizing: 'border-box', // Prevent padding issue with fullWidth. }, /* Styles applied to the `NotchedOutline` element. */ notchedOutline: {}, diff --git a/packages/material-ui/src/RadioGroup/RadioGroup.js b/packages/material-ui/src/RadioGroup/RadioGroup.js index 8dea04808cbabb..94c8be29c07abf 100644 --- a/packages/material-ui/src/RadioGroup/RadioGroup.js +++ b/packages/material-ui/src/RadioGroup/RadioGroup.js @@ -6,106 +6,104 @@ import warning from 'warning'; import FormGroup from '../FormGroup'; import { createChainedFunction, find } from '../utils/helpers'; -class RadioGroup extends React.Component { - radios = []; +const RadioGroup = React.forwardRef(function RadioGroup(props, ref) { + const { actions, children, defaultValue, name, value: valueProp, onChange, ...other } = props; + const radiosRef = React.useRef([]); + const { current: isControlled } = React.useRef(props.value != null); + const [valueState, setValue] = React.useState(() => { + if (!isControlled) { + return defaultValue; + } + return null; + }); - constructor(props) { - super(); - this.isControlled = props.value != null; + React.useImperativeHandle(actions, () => ({ + focus: () => { + const radios = radiosRef.current; + if (!radios.length) { + return; + } - if (!this.isControlled) { - this.state = { - value: props.defaultValue, - }; - } - } + const focusRadios = radios.filter(n => !n.disabled); + + if (!focusRadios.length) { + return; + } + + const selectedRadio = find(focusRadios, n => n.checked); + + if (selectedRadio) { + selectedRadio.focus(); + return; + } - componentDidUpdate() { + focusRadios[0].focus(); + }, + })); + + React.useEffect(() => { warning( - this.isControlled === (this.props.value != null), + isControlled === (valueProp != null), [ `Material-UI: A component is changing ${ - this.isControlled ? 'a ' : 'an un' - }controlled RadioGroup to be ${this.isControlled ? 'un' : ''}controlled.`, + isControlled ? 'a ' : 'an un' + }controlled RadioGroup to be ${isControlled ? 'un' : ''}controlled.`, 'Input elements should not switch from uncontrolled to controlled (or vice versa).', 'Decide between using a controlled or uncontrolled RadioGroup ' + 'element for the lifetime of the component.', 'More info: https://fb.me/react-controlled-components', ].join('\n'), ); - } - - focus = () => { - if (!this.radios || !this.radios.length) { - return; - } + }, [valueProp, isControlled]); - const focusRadios = this.radios.filter(n => !n.disabled); + const value = isControlled ? valueProp : valueState; - if (!focusRadios.length) { - return; + const handleChange = event => { + if (!isControlled) { + setValue(event.target.value); } - const selectedRadio = find(focusRadios, n => n.checked); - - if (selectedRadio) { - selectedRadio.focus(); - return; + if (onChange) { + onChange(event, event.target.value); } - - focusRadios[0].focus(); }; - handleChange = event => { - if (!this.isControlled) { - this.setState({ - value: event.target.value, - }); - } - - if (this.props.onChange) { - this.props.onChange(event, event.target.value); - } - }; - - render() { - const { children, name, value: valueProp, onChange, ...other } = this.props; - - const value = this.isControlled ? valueProp : this.state.value; - this.radios = []; - - return ( - - {React.Children.map(children, child => { - if (!React.isValidElement(child)) { - return null; - } - - warning( - child.type !== React.Fragment, - [ - "Material-UI: the RadioGroup component doesn't accept a Fragment as a child.", - 'Consider providing an array instead.', - ].join('\n'), - ); - - return React.cloneElement(child, { - name, - inputRef: node => { - if (node) { - this.radios.push(node); - } - }, - checked: value === child.props.value, - onChange: createChainedFunction(child.props.onChange, this.handleChange), - }); - })} - - ); - } -} + radiosRef.current = []; + return ( + + {React.Children.map(children, child => { + if (!React.isValidElement(child)) { + return null; + } + + warning( + child.type !== React.Fragment, + [ + "Material-UI: the RadioGroup component doesn't accept a Fragment as a child.", + 'Consider providing an array instead.', + ].join('\n'), + ); + + return React.cloneElement(child, { + name, + inputRef: node => { + if (node) { + radiosRef.current.push(node); + } + }, + checked: value === child.props.value, + onChange: createChainedFunction(child.props.onChange, handleChange), + }); + })} + + ); +}); RadioGroup.propTypes = { + /** + * @ignore + */ + actions: PropTypes.shape({ current: PropTypes.object }), /** * The content of the component. */ diff --git a/packages/material-ui/src/RadioGroup/RadioGroup.test.js b/packages/material-ui/src/RadioGroup/RadioGroup.test.js index 477fe6cce63b11..36af95fa928ab4 100644 --- a/packages/material-ui/src/RadioGroup/RadioGroup.test.js +++ b/packages/material-ui/src/RadioGroup/RadioGroup.test.js @@ -1,170 +1,178 @@ import React from 'react'; import { assert } from 'chai'; import { spy } from 'sinon'; -import { createShallow, createMount } from '@material-ui/core/test-utils'; +import { createMount, findOutermostIntrinsic, testRef } from '@material-ui/core/test-utils'; import FormGroup from '../FormGroup'; import Radio from '../Radio'; import RadioGroup from './RadioGroup'; import consoleErrorMock from 'test/utils/consoleErrorMock'; +import { act } from 'react-dom/test-utils'; describe('', () => { - let shallow; let mount; before(() => { mount = createMount(); - shallow = createShallow(); }); after(() => { mount.cleanUp(); }); + function findRadio(wrapper, value) { + return wrapper.find(`input[value="${value}"]`).first(); + } + + it('does forward refs', () => { + testRef(, mount); + }); + it('should render a FormGroup with the radiogroup role', () => { - const wrapper = shallow(); - assert.strictEqual(wrapper.type(), FormGroup); - assert.strictEqual(wrapper.props().role, 'radiogroup'); + const wrapper = mount(); + assert.strictEqual(wrapper.childAt(0).type(), FormGroup); + assert.strictEqual(findOutermostIntrinsic(wrapper).props().role, 'radiogroup'); }); it('should fire the onBlur callback', () => { const handleBlur = spy(); - const wrapper = shallow(); - const event = {}; - wrapper.simulate('blur', event); + const wrapper = mount(); + + const eventMock = 'something-to-match'; + wrapper.simulate('blur', { eventMock }); assert.strictEqual(handleBlur.callCount, 1); - assert.strictEqual(handleBlur.args[0][0], event); + assert.strictEqual(handleBlur.calledWithMatch({ eventMock }), true); }); it('should fire the onKeyDown callback', () => { const handleKeyDown = spy(); - const wrapper = shallow(); - const event = {}; - wrapper.simulate('keyDown', event); + const wrapper = mount(); + + const eventMock = 'something-to-match'; + wrapper.simulate('keyDown', { eventMock }); assert.strictEqual(handleKeyDown.callCount, 1); - assert.strictEqual(handleKeyDown.args[0][0], event); + assert.strictEqual(handleKeyDown.calledWithMatch({ eventMock }), true); }); it('should support uncontrolled mode', () => { - const wrapper = shallow( + const wrapper = mount( , ); - const radio = wrapper.children().first(); - const event = { target: { value: 'one' } }; - radio.simulate('change', event, true); - assert.strictEqual( - wrapper - .children() - .first() - .props().checked, - true, - ); + findRadio(wrapper, 'one').simulate('change'); + assert.strictEqual(findRadio(wrapper, 'one').props().checked, true); }); it('should support default value in uncontrolled mode', () => { - const wrapper = shallow( + const wrapper = mount( , ); - assert.strictEqual( - wrapper - .children() - .first() - .props().checked, - true, - ); - - const radio = wrapper.children().last(); - const event = { target: { value: 'one' } }; - radio.simulate('change', event, true); - - assert.strictEqual( - wrapper - .children() - .last() - .props().checked, - true, - ); + assert.strictEqual(findRadio(wrapper, 'zero').props().checked, true); + findRadio(wrapper, 'one').simulate('change'); + assert.strictEqual(findRadio(wrapper, 'one').props().checked, true); }); describe('imperative focus()', () => { - let wrapper; - - beforeEach(() => { - wrapper = shallow(); - }); - it('should focus the first non-disabled radio', () => { - const radios = [ - { disabled: true, focus: spy() }, - { disabled: false, focus: spy() }, - { disabled: false, focus: spy() }, - ]; - wrapper.instance().radios = radios; - wrapper.instance().focus(); - - assert.strictEqual(radios[1].focus.callCount, 1); + const actionsRef = React.createRef(); + const oneRadioOnFocus = spy(); + + mount( + + + + + , + ); + + actionsRef.current.focus(); + assert.strictEqual(oneRadioOnFocus.callCount, 1); }); it('should not focus any radios if all are disabled', () => { - const radios = [ - { disabled: true, focus: spy() }, - { disabled: true, focus: spy() }, - { disabled: true, focus: spy() }, - ]; - wrapper.instance().radios = radios; - wrapper.instance().focus(); - - assert.strictEqual(radios[0].focus.callCount, 0); - assert.strictEqual(radios[1].focus.callCount, 0); - assert.strictEqual(radios[2].focus.callCount, 0); + const actionsRef = React.createRef(); + const zeroRadioOnFocus = spy(); + const oneRadioOnFocus = spy(); + const twoRadioOnFocus = spy(); + + mount( + + + + + , + ); + + actionsRef.current.focus(); + + assert.strictEqual(zeroRadioOnFocus.callCount, 0); + assert.strictEqual(oneRadioOnFocus.callCount, 0); + assert.strictEqual(twoRadioOnFocus.callCount, 0); }); it('should focus the selected radio', () => { - const radios = [ - { disabled: true, focus: spy() }, - { disabled: false, focus: spy() }, - { disabled: false, checked: true, focus: spy() }, - { disabled: false, focus: spy() }, - ]; - wrapper.instance().radios = radios; - wrapper.instance().focus(); - - assert.strictEqual(radios[0].focus.callCount, 0); - assert.strictEqual(radios[1].focus.callCount, 0); - assert.strictEqual(radios[2].focus.callCount, 1); - assert.strictEqual(radios[3].focus.callCount, 0); + const actionsRef = React.createRef(); + const zeroRadioOnFocus = spy(); + const oneRadioOnFocus = spy(); + const twoRadioOnFocus = spy(); + const threeRadioOnFocus = spy(); + + mount( + + + + + + , + ); + + actionsRef.current.focus(); + + assert.strictEqual(zeroRadioOnFocus.callCount, 0); + assert.strictEqual(oneRadioOnFocus.callCount, 0); + assert.strictEqual(twoRadioOnFocus.callCount, 1); + assert.strictEqual(threeRadioOnFocus.callCount, 0); }); it('should focus the non-disabled radio rather than the disabled selected radio', () => { - const radios = [ - { disabled: true, focus: spy() }, - { disabled: true, focus: spy() }, - { disabled: true, checked: true, focus: spy() }, - { disabled: false, focus: spy() }, - ]; - wrapper.instance().radios = radios; - wrapper.instance().focus(); - - assert.strictEqual(radios[0].focus.callCount, 0); - assert.strictEqual(radios[1].focus.callCount, 0); - assert.strictEqual(radios[2].focus.callCount, 0); - assert.strictEqual(radios[3].focus.callCount, 1); + const actionsRef = React.createRef(); + const zeroRadioOnFocus = spy(); + const oneRadioOnFocus = spy(); + const twoRadioOnFocus = spy(); + const threeRadioOnFocus = spy(); + + mount( + + + + + + , + ); + + actionsRef.current.focus(); + + assert.strictEqual(zeroRadioOnFocus.callCount, 0); + assert.strictEqual(oneRadioOnFocus.callCount, 0); + assert.strictEqual(twoRadioOnFocus.callCount, 0); + assert.strictEqual(threeRadioOnFocus.callCount, 1); }); it('should be able to focus with no radios', () => { - wrapper.instance().radios = []; - wrapper.instance().focus(); + const actionsRef = React.createRef(); + mount(); + + actionsRef.current.focus(); }); }); it('should accept invalid child', () => { - shallow( + mount( {null} @@ -175,53 +183,35 @@ describe('', () => { describe('prop: onChange', () => { it('should fire onChange', () => { const handleChange = spy(); - const wrapper = shallow( + const wrapper = mount( - + , ); - const internalRadio = wrapper.children().first(); - const event = { target: { value: 'woofRadioGroup' } }; - internalRadio.simulate('change', event, true); + const eventMock = 'something-to-match'; + findRadio(wrapper, 'woofRadioGroup').simulate('change', { eventMock }); assert.strictEqual(handleChange.callCount, 1); - assert.strictEqual(handleChange.calledWith(event), true); + assert.strictEqual(handleChange.calledWithMatch({ eventMock }), true); }); it('should chain the onChange property', () => { const handleChange1 = spy(); const handleChange2 = spy(); - const wrapper = shallow( + const wrapper = mount( - + , ); - const internalRadio = wrapper.children().first(); - internalRadio.simulate('change', { target: { value: 'woofRadioGroup' } }, true); + findRadio(wrapper, 'woofRadioGroup').simulate('change'); assert.strictEqual(handleChange1.callCount, 1); assert.strictEqual(handleChange2.callCount, 1); }); }); - describe('register internal radios to this.radio', () => { - it('should add a child', () => { - const wrapper = mount( - - - , - ); - assert.strictEqual(wrapper.instance().radios.length, 1); - }); - - it('should keep radios empty', () => { - const wrapper = mount(); - assert.strictEqual(wrapper.instance().radios.length, 0); - }); - }); - describe('warnings', () => { beforeEach(() => { consoleErrorMock.spy(); @@ -237,7 +227,11 @@ describe('', () => { , ); - wrapper.setProps({ value: undefined }); + + act(() => { + wrapper.setProps({ value: undefined }); + }); + assert.include( consoleErrorMock.args()[0][0], 'A component is changing a controlled RadioGroup to be uncontrolled.', @@ -250,7 +244,11 @@ describe('', () => { , ); - wrapper.setProps({ value: 'foo' }); + + act(() => { + wrapper.setProps({ value: 'foo' }); + }); + assert.include( consoleErrorMock.args()[0][0], 'A component is changing an uncontrolled RadioGroup to be controlled.', diff --git a/packages/material-ui/src/test-utils/testRef.d.ts b/packages/material-ui/src/test-utils/testRef.d.ts index 687d8ed94148c4..501179ea9c3b5e 100644 --- a/packages/material-ui/src/test-utils/testRef.d.ts +++ b/packages/material-ui/src/test-utils/testRef.d.ts @@ -3,10 +3,14 @@ import createMount from './createMount'; /** * Utility method to make assertions about the ref on an element - * @param onRef - Make your assertions here + * @param element - The element should have a component wrapped + * in withStyles as the root + * @param mount - Should be returnvalue of createMount + * @param onRef - Callback, first arg is the ref. + * Assert that the ref is a DOM node by default */ export default function testRef( element: React.ReactElement<{ innerRef: React.RefObject }>, mount: ReturnType, - onRef: (ref: T) => void, + onRef?: (ref: T) => void, ): void; diff --git a/packages/material-ui/src/test-utils/testRef.js b/packages/material-ui/src/test-utils/testRef.js index 72773e254a2993..1722258276d66e 100644 --- a/packages/material-ui/src/test-utils/testRef.js +++ b/packages/material-ui/src/test-utils/testRef.js @@ -7,7 +7,7 @@ function assertDOMNode(node) { } /** - * + * Utility method to make assertions about the ref on an element * @param {React.ReactElement} element - The element should have a component wrapped * in withStyles as the root * @param {function} mount - Should be returnvalue of createMount