Skip to content

Commit be4fa07

Browse files
refactor(react): update SideNavMenu to functional component (#9910)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
1 parent 532a5c9 commit be4fa07

7 files changed

Lines changed: 410 additions & 2 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# `carbon-components-react`
2+
3+
**Note: everything in this file is a work-in-progress and will be changed.**
4+
5+
<!-- prettier-ignore-start -->
6+
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
7+
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
8+
## Table of Contents
9+
10+
- [Components](#components)
11+
- [SideNavMenu](#sidenavmenu)
12+
13+
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
14+
<!-- prettier-ignore-end -->
15+
16+
## Overview
17+
18+
The `carbon-components-react` package has been replaced by the `@carbon/react`
19+
package in v11. While you can still use `carbon-components-react` along with
20+
v11, it has been deprecated and will stop receiving updates when v12 is
21+
released. To learn more about this change, checkout WIP LINK.
22+
23+
In addition, this release contains updates to React components in the package.
24+
These updates include:
25+
26+
- Changing components from class components to functional components
27+
- Creating consistent prop names and types across components
28+
- Updates to make components more accessible
29+
30+
For a full overview of changes to components, checkout our
31+
[components](#components) section below.
32+
33+
## Components
34+
35+
| Component | Changes |
36+
| :------------ | :-------------------------------------------------------------- |
37+
| `SideNavMenu` | Updated from a class to functional component. No other changes. |
38+
39+
## FAQ

packages/react/src/components/UIShell/__tests__/SideNavMenu-test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { SideNavMenu } from '../SideNavMenu';
1111
import { SideNavMenuItem } from '../';
1212

1313
const prefix = 'bx';
14+
1415
describe('SideNavMenu', () => {
1516
let mockProps, wrapper;
1617

packages/react/src/components/UIShell/index.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8+
import * as FeatureFlags from '@carbon/feature-flags';
9+
import SideNavMenuClassic from './SideNavMenu';
10+
import { SideNavMenu as SideNavMenuNext } from './next/SideNavMenu';
11+
812
export Content from './Content';
913

1014
export Header from './Header';
@@ -18,7 +22,6 @@ export HeaderName from './HeaderName';
1822
export HeaderNavigation from './HeaderNavigation';
1923
export HeaderPanel from './HeaderPanel';
2024
export HeaderSideNavItems from './HeaderSideNavItems';
21-
2225
export Switcher from './Switcher';
2326
export SwitcherItem from './SwitcherItem';
2427
export SwitcherDivider from './SwitcherDivider';
@@ -35,6 +38,8 @@ export SideNavItem from './SideNavItem';
3538
export SideNavItems from './SideNavItems';
3639
export SideNavLink from './SideNavLink';
3740
export SideNavLinkText from './SideNavLinkText';
38-
export SideNavMenu from './SideNavMenu';
41+
export const SideNavMenu = FeatureFlags.enabled('enable-v11-release')
42+
? SideNavMenuNext
43+
: SideNavMenuClassic;
3944
export SideNavMenuItem from './SideNavMenuItem';
4045
export SideNavSwitcher from './SideNavSwitcher';
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/**
2+
* Copyright IBM Corp. 2016, 2018
3+
*
4+
* This source code is licensed under the Apache-2.0 license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import { ChevronDown20 } from '@carbon/icons-react';
9+
import cx from 'classnames';
10+
import PropTypes from 'prop-types';
11+
import React, { useState } from 'react';
12+
import SideNavIcon from '../SideNavIcon';
13+
import { keys, match } from '../../../internal/keyboard';
14+
import { usePrefix } from '../../../internal/usePrefix';
15+
16+
const SideNavMenu = React.forwardRef(function SideNavMenu(props, ref) {
17+
const {
18+
className: customClassName,
19+
children,
20+
defaultExpanded = false,
21+
isActive = false,
22+
large = false,
23+
renderIcon: IconElement,
24+
isSideNavExpanded,
25+
title,
26+
} = props;
27+
const prefix = usePrefix();
28+
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
29+
const [prevExpanded, setPrevExpanded] = useState(defaultExpanded);
30+
const className = cx({
31+
[`${prefix}--side-nav__item`]: true,
32+
[`${prefix}--side-nav__item--active`]:
33+
isActive || (hasActiveChild(children) && !isExpanded),
34+
[`${prefix}--side-nav__item--icon`]: IconElement,
35+
[`${prefix}--side-nav__item--large`]: large,
36+
[customClassName]: !!customClassName,
37+
});
38+
39+
if (isSideNavExpanded === false && isExpanded === true) {
40+
setIsExpanded(false);
41+
setPrevExpanded(true);
42+
} else if (isSideNavExpanded === true && prevExpanded === true) {
43+
setIsExpanded(true);
44+
setPrevExpanded(false);
45+
}
46+
47+
return (
48+
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
49+
<li
50+
className={className}
51+
onKeyDown={(event) => {
52+
if (match(event, keys.Escape)) {
53+
setIsExpanded(false);
54+
}
55+
}}>
56+
<button
57+
aria-expanded={isExpanded}
58+
className={`${prefix}--side-nav__submenu`}
59+
onClick={() => {
60+
setIsExpanded(!isExpanded);
61+
}}
62+
ref={ref}
63+
type="button">
64+
{IconElement && (
65+
<SideNavIcon>
66+
<IconElement />
67+
</SideNavIcon>
68+
)}
69+
<span className={`${prefix}--side-nav__submenu-title`}>{title}</span>
70+
<SideNavIcon className={`${prefix}--side-nav__submenu-chevron`} small>
71+
<ChevronDown20 />
72+
</SideNavIcon>
73+
</button>
74+
<ul className={`${prefix}--side-nav__menu`}>{children}</ul>
75+
</li>
76+
);
77+
});
78+
79+
SideNavMenu.propTypes = {
80+
/**
81+
* Provide <SideNavMenuItem>'s inside of the `SideNavMenu`
82+
*/
83+
children: PropTypes.node,
84+
85+
/**
86+
* Provide an optional class to be applied to the containing node
87+
*/
88+
className: PropTypes.string,
89+
90+
/**
91+
* Specify whether the menu should default to expanded. By default, it will
92+
* be closed.
93+
*/
94+
defaultExpanded: PropTypes.bool,
95+
96+
/**
97+
* Specify whether the `SideNavMenu` is "active". `SideNavMenu` should be
98+
* considered active if one of its menu items are a link for the current
99+
* page.
100+
*/
101+
isActive: PropTypes.bool,
102+
103+
/**
104+
* Property to indicate if the side nav container is open (or not). Use to
105+
* keep local state and styling in step with the SideNav expansion state.
106+
*/
107+
isSideNavExpanded: PropTypes.bool,
108+
109+
/**
110+
* Specify if this is a large variation of the SideNavMenu
111+
*/
112+
large: PropTypes.bool,
113+
114+
/**
115+
* Pass in a custom icon to render next to the `SideNavMenu` title
116+
*/
117+
renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
118+
119+
/**
120+
* Provide the text for the overall menu name
121+
*/
122+
title: PropTypes.string.isRequired,
123+
};
124+
125+
function hasActiveChild(children) {
126+
// if we have children, either a single or multiple, find if it is active
127+
if (Array.isArray(children)) {
128+
return children.some((child) => {
129+
if (!child.props) {
130+
return false;
131+
}
132+
133+
if (child.props.isActive === true) {
134+
return true;
135+
}
136+
137+
if (child.props['aria-current']) {
138+
return true;
139+
}
140+
141+
return false;
142+
});
143+
}
144+
145+
if (children.props) {
146+
if (children.props.isActive === true || children.props['aria-current']) {
147+
return true;
148+
}
149+
}
150+
151+
return false;
152+
}
153+
154+
export { SideNavMenu };
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* Copyright IBM Corp. 2016, 2018
3+
*
4+
* This source code is licensed under the Apache-2.0 license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import React from 'react';
9+
import { mount } from 'enzyme';
10+
import { SideNavMenu } from '../SideNavMenu';
11+
import { SideNavMenuItem } from '../../';
12+
13+
const prefix = 'bx';
14+
15+
describe('SideNavMenu', () => {
16+
let mockProps;
17+
18+
beforeEach(() => {
19+
mockProps = {
20+
ref: jest.fn(),
21+
className: 'custom-classname',
22+
children: <span data-testid="children">test</span>,
23+
renderIcon: () => <div>icon</div>,
24+
isActive: false,
25+
title: 'title',
26+
};
27+
});
28+
29+
it('should render', () => {
30+
const wrapper = mount(<SideNavMenu {...mockProps} />);
31+
expect(wrapper).toMatchSnapshot();
32+
});
33+
34+
it('should expand the menu when the button ref is clicked', () => {
35+
const wrapper = mount(<SideNavMenu {...mockProps} />);
36+
37+
expect(wrapper.find('button').prop('aria-expanded')).toBe(false);
38+
expect(mockProps.ref).toHaveBeenCalledTimes(1);
39+
40+
wrapper.find('button').simulate('click');
41+
42+
expect(wrapper.find('button').prop('aria-expanded')).toBe(true);
43+
});
44+
45+
it('should collapse the menu when the Esc key is pressed', () => {
46+
const wrapper = mount(<SideNavMenu {...mockProps} defaultExpanded />);
47+
48+
expect(wrapper.find('button').prop('aria-expanded')).toBe(true);
49+
50+
wrapper.simulate('keydown', {
51+
key: 'Escape',
52+
keyCode: 27,
53+
which: 27,
54+
});
55+
56+
expect(wrapper.find('button').prop('aria-expanded')).toBe(false);
57+
});
58+
59+
it('should reset expanded state if the isSideNavExpanded prop is false', () => {
60+
const wrapper = mount(<SideNavMenu {...mockProps} />);
61+
62+
expect(wrapper.find('button').prop('aria-expanded')).toBe(false);
63+
wrapper.find('button').simulate('click');
64+
expect(wrapper.find('button').prop('aria-expanded')).toBe(true);
65+
66+
// set the prop to false. This should force isExpanded from true to false, and update wasPreviouslyExpanded to true
67+
wrapper.setProps({ isSideNavExpanded: false });
68+
expect(wrapper.find('button').prop('aria-expanded')).toBe(false);
69+
});
70+
71+
it('should reset expanded state if the SideNav was collapsed/expanded', () => {
72+
const wrapper = mount(
73+
<SideNavMenu {...mockProps} defaultExpanded isSideNavExpanded={false} />
74+
);
75+
76+
// set the prop to false. This should force isExpanded from true to false, and update wasPreviouslyExpanded to true
77+
wrapper.setProps({ isSideNavExpanded: true });
78+
79+
expect(wrapper.find('button').prop('aria-expanded')).toBe(true);
80+
});
81+
82+
it('should add the correct active class if a child is active', () => {
83+
const wrapper = mount(<SideNavMenu {...mockProps} />);
84+
expect(
85+
wrapper.find('li').hasClass(`${prefix}--side-nav__item--active`)
86+
).toBe(false);
87+
// add a (single) child which is active
88+
wrapper.setProps({
89+
children: <SideNavMenuItem isActive={true}>Test</SideNavMenuItem>,
90+
});
91+
expect(
92+
wrapper.find('li').at(0).hasClass(`${prefix}--side-nav__item--active`)
93+
).toBe(true);
94+
wrapper.setProps({
95+
children: [
96+
<SideNavMenuItem key="first">entry one</SideNavMenuItem>,
97+
<SideNavMenuItem key="second" aria-current="page">
98+
entry two
99+
</SideNavMenuItem>,
100+
],
101+
});
102+
expect(
103+
wrapper.find('li').at(0).hasClass(`${prefix}--side-nav__item--active`)
104+
).toBe(true);
105+
});
106+
107+
it('should include a css class to render the large variant is large prop is set', () => {
108+
const wrapper = mount(<SideNavMenu {...mockProps} />);
109+
expect(
110+
wrapper.find('li').hasClass(`${prefix}--side-nav__item--large`)
111+
).toBe(false);
112+
wrapper.setProps({ large: true });
113+
expect(
114+
wrapper.find('li').hasClass(`${prefix}--side-nav__item--large`)
115+
).toBe(true);
116+
});
117+
});

0 commit comments

Comments
 (0)