Repository: uber-web/baseui
Pull request #728 (2019-01-10 18:34:21)

feat(menu): support disabled options
Adds support for disabled options, using the same treatment as Select.

This also fixes StatefulMenu's handling of clicking options. Previously, it would not properly highlight options that were clicked; it only worked through the keyboard.


Patch:
diff --git a/src/menu/constants.js b/src/menu/constants.js
index 07726c4ec..6fbc61d4d 100644
--- a/src/menu/constants.js
+++ b/src/menu/constants.js
@@ -7,12 +7,14 @@ LICENSE file in the root directory of this source tree.
 // @flow
 
 export const STATE_CHANGE_TYPES = {
+  click: 'click',
   moveUp: 'moveUp',
   moveDown: 'moveDown',
 };
 
 // Dict of props to prepend $ to pass thru to styletron
 export const SHARED_PROPS_MAPPER = {
+  disabled: true,
   ref: true,
   isHighlighted: true,
 };
diff --git a/src/menu/examples.js b/src/menu/examples.js
index ae077d89b..f444cda2d 100644
--- a/src/menu/examples.js
+++ b/src/menu/examples.js
@@ -43,7 +43,7 @@ function CloudComponent() {
 const ITEMS = [
   {label: 'Item One'},
   {label: 'Item Two'},
-  {label: 'Item Three'},
+  {disabled: true, label: 'Item Three'},
   {label: 'Item Four'},
   {label: 'Item Five'},
   {label: 'Item Six'},
diff --git a/src/menu/stateful-container.js b/src/menu/stateful-container.js
index 6b68411f2..f61de0e93 100644
--- a/src/menu/stateful-container.js
+++ b/src/menu/stateful-container.js
@@ -118,7 +118,11 @@ export default class MenuStatefulContainer extends React.Component<
   handleEnterKey(event: KeyboardEvent) {
     const {items, onItemSelect} = this.props;
     const {highlightedIndex} = this.state;
-    if (items[highlightedIndex] && onItemSelect) {
+    if (
+      items[highlightedIndex] &&
+      onItemSelect &&
+      !items[highlightedIndex].disabled
+    ) {
       onItemSelect({item: items[highlightedIndex], event});
     }
   }
@@ -127,8 +131,13 @@ export default class MenuStatefulContainer extends React.Component<
     const {highlightedIndex} = this.state;
     const {onItemSelect, getRequiredItemProps} = this.props;
     let onClickHandler;
-    if (onItemSelect) {
-      onClickHandler = onItemSelect.bind(null, {item});
+    if (onItemSelect && !item.disabled) {
+      onClickHandler = () => {
+        onItemSelect({item});
+        this.internalSetState(STATE_CHANGE_TYPES.click, {
+          highlightedIndex: index,
+        });
+      };
     }
     // Create and store ref or re-use
     let itemRef = this.refList[index];
@@ -137,6 +146,7 @@ export default class MenuStatefulContainer extends React.Component<
       this.refList[index] = itemRef;
     }
     return {
+      disabled: item.disabled,
       ref: itemRef,
       isHighlighted: highlightedIndex === index,
       onClick: onClickHandler,
diff --git a/src/menu/styled-components.js b/src/menu/styled-components.js
index 7ab51f83f..106bebd4a 100644
--- a/src/menu/styled-components.js
+++ b/src/menu/styled-components.js
@@ -11,6 +11,7 @@ import {OPTION_LIST_SIZE} from './constants.js';
 import type {ThemeT} from '../styles/index.js';
 
 type StyledPropsT = {
+  $disabled?: boolean,
   $theme: ThemeT,
   $isHighlighted?: boolean,
   $size?: $Keys<typeof OPTION_LIST_SIZE>,
@@ -35,14 +36,18 @@ List.displayName = 'StyledList';
 
 export const ListItem = styled(
   'li',
-  ({$theme, $isHighlighted, $size}: StyledPropsT) => ({
+  ({$disabled, $theme, $isHighlighted, $size}: StyledPropsT) => ({
     ...($size === OPTION_LIST_SIZE.compact
       ? $theme.typography.font200
       : $theme.typography.font300),
     position: 'relative',
     display: 'block',
-    color: $isHighlighted ? $theme.colors.primary : $theme.colors.foreground,
-    cursor: 'pointer',
+    color: $disabled
+      ? $theme.colors.foregroundAlt
+      : $isHighlighted
+        ? $theme.colors.primary
+        : $theme.colors.foreground,
+    cursor: $disabled ? 'not-allowed' : 'pointer',
     backgroundColor: $isHighlighted
       ? $theme.colors.menuFillHover
       : 'transparent',
@@ -68,6 +73,9 @@ export const ListItem = styled(
       $size === OPTION_LIST_SIZE.compact
         ? $theme.sizing.scale900
         : $theme.sizing.scale600,
+    ':focus': {
+      outline: 'none',
+    },
   }),
 );
 ListItem.displayName = 'StyledListItem';


Test patch:
diff --git a/src/menu/__tests__/__snapshots__/styled-components.test.js.snap b/src/menu/__tests__/__snapshots__/styled-components.test.js.snap
index 1b29bf54a..d81b2b016 100644
--- a/src/menu/__tests__/__snapshots__/styled-components.test.js.snap
+++ b/src/menu/__tests__/__snapshots__/styled-components.test.js.snap
@@ -20,6 +20,9 @@ Object {
 
 exports[`Menu Styled Components ListItem - basic render: correct styles 1`] = `
 Object {
+  ":focus": Object {
+    "outline": "none",
+  },
   ":hover": Object {
     "backgroundColor": "$theme.colors.menuFillHover",
   },
@@ -44,6 +47,9 @@ Object {
 
 exports[`Menu Styled Components ListItem - compact: correct styles 1`] = `
 Object {
+  ":focus": Object {
+    "outline": "none",
+  },
   ":hover": Object {
     "backgroundColor": "$theme.colors.menuFillHover",
   },
@@ -68,6 +74,9 @@ Object {
 
 exports[`Menu Styled Components ListItem - highlighted render: correct styles 1`] = `
 Object {
+  ":focus": Object {
+    "outline": "none",
+  },
   ":hover": Object {
     "backgroundColor": "$theme.colors.menuFillHover",
   },
diff --git a/src/menu/__tests__/stateful-container.test.js b/src/menu/__tests__/stateful-container.test.js
index 582507152..c621f2af9 100644
--- a/src/menu/__tests__/stateful-container.test.js
+++ b/src/menu/__tests__/stateful-container.test.js
@@ -14,7 +14,7 @@ import {scrollItemIntoView} from '../utils.js';
 
 jest.mock('../utils');
 
-const mockItems = [{label: 'item1'}, {label: 'item2'}];
+const mockItems = [{label: 'item1'}, {disabled: true, label: 'item2'}];
 const mockChildrenFn = jest.fn().mockImplementation(() => <div />);
 const mockItemSelect = jest.fn();
 
@@ -102,6 +102,18 @@ describe('Menu StatefulContainer', () => {
     });
   });
 
+  test('getRequiredItemProps returns correct props for disabled item', () => {
+    const component = mount(<StatefulContainer {...getSharedProps()} />);
+    const item = mockItems[1];
+    const props = component.instance().getRequiredItemProps(item, 1);
+    expect(props).toEqual({
+      disabled: true,
+      ref: React.createRef(),
+      isHighlighted: false,
+      'aria-activedescendant': false,
+    });
+  });
+
   test('getRequiredItemProps returns correct props for active child', () => {
     const component = mount(<StatefulContainer {...getSharedProps()} />);
     component.setState({
@@ -175,5 +187,11 @@ describe('Menu StatefulContainer', () => {
         event,
       },
     ]);
+
+    component.setState({
+      highlightedIndex: 1,
+    });
+    component.instance().onKeyDown(event);
+    expect(mockItemSelect.mock.calls.length).toBe(1);
   });
 });