Repository: mui/material-ui (TypeScript)
Date: 2019-01-30 11:48:26+00:00
Task type: Feature

Issue:
Improve documentation for `ListItem` and `ListItemSecondaryAction`
Improve the documentation for how `ListItemSecondaryAction` components work within `ListItem` components.

<!--
    Thank you very much for contributing to Material-UI by creating an issue! ❤️
    To avoid duplicate issues we ask you to check off the following list.
-->

<!-- Checked checkbox should look like this: [x] -->
- [x] This is not a v0.x issue. <!-- (v0.x is no longer maintained) -->
- [x] I have searched the [issues](https://github.com/mui-org/material-ui/issues) of this repository and believe that this is not a duplicate.

## Expected Behavior 🤔

The documentation would have warnings / implementation details indicating the following:

- `ListItemSecondaryAction` *must* be the last child of `ListItem` for `ListItem` to detect it has a secondary action ([see source code](https://github.com/mui-org/material-ui/blob/a207808404a703d1ea2b03ac510343be6404b166/packages/material-ui/src/ListItem/ListItem.js#L99)).
- When using a wrapper around `ListItemSecondaryAction`, the `muiName` field *must* be set to `'ListItemSecondaryAction'` (see code above and issue #11622).
- When using a `ListItemSecondaryAction` within a `ListItem`, the component used for the `ListItem` changes (see issue #13597).

These would be useful at the top of the API documentation for [ListItemSecondaryAction](https://material-ui.com/api/list-item-secondary-action/) and possibly [ListItem](https://material-ui.com/api/list-item/).

## Current Behavior 😯

These behaviors are undocumented and require hunting through the issues and the code to figure out.

## Context 🔦
<!---
    What are you trying to accomplish? How has the lack of this feature affected you?
    Providing context helps us come up with a solution that is most useful in the real world.
-->

I spent all morning trying to figure out why when I hovered or clicked on my secondary action, it also triggered the list item. I finally figured out it was the order of the children after looking at the code. I was also using a wrapper for `ListItemSecondaryAction`, which had to be fixed too.

My use case is that I wanted the secondary action on the left side, not the right side. So I had changed the order of the children, not thinking that just changing the order would affect the behavior of `ListItem`.

Long-term, the best solution would be to improve the implementation so secondary actions are not so fragile. But I understand there may be other priorities right now.

One possibility to explore for a future API is to have the secondary action passed in via a `secondaryActionComponent` prop. Then the `ListItem` would always know whether a secondary action existed and could wrap it within a `ListItemSecondaryAction` internally.



Issue discussion:

Solution patch:
diff --git a/packages/material-ui/src/ListItem/ListItem.js b/packages/material-ui/src/ListItem/ListItem.js
--- a/packages/material-ui/src/ListItem/ListItem.js
+++ b/packages/material-ui/src/ListItem/ListItem.js
@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import classNames from 'classnames';
-import { componentPropType } from '@material-ui/utils';
+import { chainPropTypes, componentPropType } from '@material-ui/utils';
 import withStyles from '../styles/withStyles';
 import ButtonBase from '../ButtonBase';
 import { isMuiElement } from '../utils/reactHelpers';
@@ -83,6 +83,9 @@ export const styles = theme => ({
   selected: {},
 });
 
+/**
+ * Uses an additional container component if `ListItemSecondaryAction` is the last child.
+ */
 function ListItem(props) {
   const {
     alignItems,
@@ -179,9 +182,35 @@ ListItem.propTypes = {
    */
   button: PropTypes.bool,
   /**
-   * The content of the component.
+   * The content of the component. If a `ListItemSecondaryAction` is used it must
+   * be the last child.
    */
-  children: PropTypes.node,
+  children: chainPropTypes(PropTypes.node, props => {
+    const children = React.Children.toArray(props.children);
+
+    // React.Children.toArray(props.children).findLastIndex(isListItemSecondaryAction)
+    let secondaryActionIndex = -1;
+    for (let i = children.length - 1; i >= 0; i -= 1) {
+      const child = children[i];
+      if (isMuiElement(child, ['ListItemSecondaryAction'])) {
+        secondaryActionIndex = i;
+        break;
+      }
+    }
+
+    //  is ListItemSecondaryAction the last child of ListItem
+    if (secondaryActionIndex !== -1 && secondaryActionIndex !== children.length - 1) {
+      return new Error(
+        'Material-UI: you used an element after ListItemSecondaryAction. ' +
+          'For ListItem to detect that it has a secondary action ' +
+          `you must pass it has the last children to ListItem.${
+            process.env.NODE_ENV === 'test' ? Date.now() : ''
+          }`,
+      );
+    }
+
+    return null;
+  }),
   /**
    * Override or extend the styles applied to the component.
    * See [CSS API](#css-api) below for more details.
@@ -198,12 +227,11 @@ ListItem.propTypes = {
    */
   component: componentPropType,
   /**
-   * The container component used when a `ListItemSecondaryAction` is rendered.
+   * The container component used when a `ListItemSecondaryAction` is the last child.
    */
   ContainerComponent: componentPropType,
   /**
-   * Properties applied to the container element when the component
-   * is used to display a `ListItemSecondaryAction`.
+   * Properties applied to the container component if used.
    */
   ContainerProps: PropTypes.object,
   /**
diff --git a/packages/material-ui/src/ListItemSecondaryAction/ListItemSecondaryAction.js b/packages/material-ui/src/ListItemSecondaryAction/ListItemSecondaryAction.js
--- a/packages/material-ui/src/ListItemSecondaryAction/ListItemSecondaryAction.js
+++ b/packages/material-ui/src/ListItemSecondaryAction/ListItemSecondaryAction.js
@@ -13,6 +13,9 @@ export const styles = {
   },
 };
 
+/**
+ * Must be used as the last child of ListItem to function properly.
+ */
 function ListItemSecondaryAction(props) {
   const { children, classes, className, ...other } = props;
 
diff --git a/pages/api/list-item-secondary-action.md b/pages/api/list-item-secondary-action.md
--- a/pages/api/list-item-secondary-action.md
+++ b/pages/api/list-item-secondary-action.md
@@ -12,7 +12,7 @@ filename: /packages/material-ui/src/ListItemSecondaryAction/ListItemSecondaryAct
 import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction';
 ```
 
-
+Must be used as the last child of ListItem to function properly.
 
 ## Props
 
diff --git a/pages/api/list-item.md b/pages/api/list-item.md
--- a/pages/api/list-item.md
+++ b/pages/api/list-item.md
@@ -12,7 +12,7 @@ filename: /packages/material-ui/src/ListItem/ListItem.js
 import ListItem from '@material-ui/core/ListItem';
 ```
 
-
+Uses an additional container component if `ListItemSecondaryAction` is the last child.
 
 ## Props
 
@@ -20,11 +20,11 @@ import ListItem from '@material-ui/core/ListItem';
 |:-----|:-----|:--------|:------------|
 | <span class="prop-name">alignItems</span> | <span class="prop-type">enum:&nbsp;'flex-start'&nbsp;&#124;<br>&nbsp;'center'<br></span> | <span class="prop-default">'center'</span> | Defines the `align-items` style property. |
 | <span class="prop-name">button</span> | <span class="prop-type">bool</span> | <span class="prop-default">false</span> | If `true`, the list item will be a button (using `ButtonBase`). |
-| <span class="prop-name">children</span> | <span class="prop-type">node</span> |   | The content of the component. |
+| <span class="prop-name">children</span> | <span class="prop-type">node</span> |   | The content of the component. If a `ListItemSecondaryAction` is used it must be the last child. |
 | <span class="prop-name">classes</span> | <span class="prop-type">object</span> |   | Override or extend the styles applied to the component. See [CSS API](#css-api) below for more details. |
 | <span class="prop-name">component</span> | <span class="prop-type">Component</span> |   | The component used for the root node. Either a string to use a DOM element or a component. By default, it's a `li` when `button` is `false` and a `div` when `button` is `true`. |
-| <span class="prop-name">ContainerComponent</span> | <span class="prop-type">Component</span> | <span class="prop-default">'li'</span> | The container component used when a `ListItemSecondaryAction` is rendered. |
-| <span class="prop-name">ContainerProps</span> | <span class="prop-type">object</span> |   | Properties applied to the container element when the component is used to display a `ListItemSecondaryAction`. |
+| <span class="prop-name">ContainerComponent</span> | <span class="prop-type">Component</span> | <span class="prop-default">'li'</span> | The container component used when a `ListItemSecondaryAction` is the last child. |
+| <span class="prop-name">ContainerProps</span> | <span class="prop-type">object</span> |   | Properties applied to the container component if used. |
 | <span class="prop-name">dense</span> | <span class="prop-type">bool</span> | <span class="prop-default">false</span> | If `true`, compact vertical padding designed for keyboard and mouse input will be used. |
 | <span class="prop-name">disabled</span> | <span class="prop-type">bool</span> | <span class="prop-default">false</span> | If `true`, the list item will be disabled. |
 | <span class="prop-name">disableGutters</span> | <span class="prop-type">bool</span> | <span class="prop-default">false</span> | If `true`, the left and right padding is removed. |


Test patch:
diff --git a/packages/material-ui/src/ListItem/ListItem.test.js b/packages/material-ui/src/ListItem/ListItem.test.js
--- a/packages/material-ui/src/ListItem/ListItem.test.js
+++ b/packages/material-ui/src/ListItem/ListItem.test.js
@@ -1,6 +1,7 @@
 import React from 'react';
 import { assert } from 'chai';
 import { getClasses, createMount, findOutermostIntrinsic } from '@material-ui/core/test-utils';
+import consoleErrorMock from 'test/utils/consoleErrorMock';
 import ListItemText from '../ListItemText';
 import ListItemSecondaryAction from '../ListItemSecondaryAction';
 import ListItem from './ListItem';
@@ -166,6 +167,31 @@ describe('<ListItem />', () => {
       assert.strictEqual(listItem.hasClass(classes.container), true);
       assert.strictEqual(listItem.hasClass('bubu'), true);
     });
+
+    describe('warnings', () => {
+      beforeEach(() => {
+        consoleErrorMock.spy();
+      });
+
+      afterEach(() => {
+        consoleErrorMock.reset();
+      });
+
+      it('warns if it cant detect the secondary action properly', () => {
+        mount(
+          <ListItem>
+            <ListItemSecondaryAction>I should have come last :(</ListItemSecondaryAction>
+            <ListItemText>My position doesn not matter.</ListItemText>
+          </ListItem>,
+        );
+
+        assert.strictEqual(consoleErrorMock.callCount(), 1);
+        assert.include(
+          consoleErrorMock.args()[0][0],
+          'Warning: Failed prop type: Material-UI: you used an element',
+        );
+      });
+    });
   });
 
   describe('prop: focusVisibleClassName', () => {