Skip to content

Commit

Permalink
refactor: update SideNav state management (#2208)
Browse files Browse the repository at this point in the history
* refactor: update SideNav state management

* test: update snapshots

* test: update snapshots and tests

* refactor: change listener API, pass dom event

* test: update snapshots

* fix: handleToggle arguments

* refactor: use strict undefined check for control
  • Loading branch information
vpicone authored and asudoh committed Apr 13, 2019
1 parent 791a174 commit 4e0e7f0
Show file tree
Hide file tree
Showing 5 changed files with 115 additions and 230 deletions.
215 changes: 102 additions & 113 deletions src/components/UIShell/SideNav.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,131 +5,120 @@
* LICENSE file in the root directory of this source tree.
*/

import React, { useState, useRef } from 'react';
import { settings } from 'carbon-components';
import cx from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
import { AriaLabelPropType } from '../../prop-types/AriaPropTypes';
import SideNavFooter from './SideNavFooter';

const { prefix } = settings;

const translations = {
'carbon.sidenav.state.open': 'Close',
'carbon.sidenav.state.closed': 'Open',
};

function translateById(id) {
return translations[id];
}

export default class SideNav extends React.Component {
static propTypes = {
/**
* Specify whether the side navigation is expanded or collapsed
*/
isExpanded: PropTypes.bool,
/**
* Required props for accessibility label on the underlying menu
*/
...AriaLabelPropType,

/**
* Optionally provide a custom class to apply to the underlying <li> node
*/
className: PropTypes.string,

/**
* Provide a custom function for translating all message ids within this
* component. This function will take in two arguments: the mesasge Id and the
* state of the component. From this, you should return a string representing
* the label you want displayed or read by screen readers.
*/
translateById: PropTypes.func,
const SideNav = React.forwardRef(function SideNav(props, ref) {
const {
expanded: expandedProp,
defaultExpanded,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
children,
onToggle,
className: customClassName,
translateById: t,
} = props;
const { current: controlled } = useRef(expandedProp !== undefined);
const [expandedState, setExpandedState] = useState(defaultExpanded);
const expanded = controlled ? expandedProp : expandedState;

const handleToggle = (event, value = !expanded) => {
if (!controlled) {
setExpandedState(value);
}
if (onToggle) {
onToggle(event, value);
}
};

static defaultProps = {
translateById,
isExpanded: false,
const accessibilityLabel = {
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
};

constructor(props) {
super(props);

this.state = {
prevIsExpanded: props.isExpanded,
isExpanded: props.isExpanded,
isFocused: false,
const assistiveText = expanded
? t('carbon.sidenav.state.open')
: t('carbon.sidenav.state.closed');

const className = cx({
[`${prefix}--side-nav`]: true,
[`${prefix}--side-nav--expanded`]: expanded,
[customClassName]: !!customClassName,
});

return (
<nav
ref={ref}
className={`${prefix}--side-nav__navigation ${className}`}
{...accessibilityLabel}
onFocus={event => handleToggle(event, true)}
onBlur={event => handleToggle(event, false)}>
{children}
<SideNavFooter
assistiveText={assistiveText}
expanded={expanded}
onToggle={handleToggle}
/>
</nav>
);
});

SideNav.defaultProps = {
translateById: id => {
const translations = {
'carbon.sidenav.state.open': 'Close',
'carbon.sidenav.state.closed': 'Open',
};
}

static getDerivedStateFromProps({ isExpanded }, state) {
return state && state.prevIsExpanded === isExpanded
? null
: {
prevIsExpanded: isExpanded,
isExpanded,
};
}

handleExpand = () => {
this.setState(state => ({ isExpanded: !state.isExpanded }));
};

handleFocus = () => {
this.setState(state => {
if (!state.isFocused) {
return { isFocused: true };
}
return null;
});
};
return translations[id];
},
defaultExpanded: false,
};

handleBlur = () => {
this.setState(state => {
if (state.isFocused) {
return { isFocused: false };
}
return null;
});
};
SideNav.propTypes = {
/**
* If `true`, the SideNav will be expanded, otherwise it will be collapsed.
* Using this prop causes SideNav to become a controled component.
*/
expanded: PropTypes.bool,

/**
* If `true`, the SideNav will be open on initial render.
*/
defaultExpanded: PropTypes.bool,

/**
* An optional listener that is called when an event that would cause
* toggling the SideNav occurs.
*
* @param {object} event
* @param {boolean} value
*/
onToggle: PropTypes.func,

/**
* Required props for accessibility label on the underlying menu
*/
...AriaLabelPropType,

/**
* Optionally provide a custom class to apply to the underlying <li> node
*/
className: PropTypes.string,

/**
* Provide a custom function for translating all message ids within this
* component. This function will take in two arguments: the mesasge Id and the
* state of the component. From this, you should return a string representing
* the label you want displayed or read by screen readers.
*/
translateById: PropTypes.func,
};

render() {
const {
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
children,
className: customClassName,
translateById: t,
} = this.props;
const { isExpanded, isFocused } = this.state;
const accessibilityLabel = {
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
};
const assistiveText =
isExpanded || isFocused
? t('carbon.sidenav.state.open')
: t('carbon.sidenav.state.closed');
const className = cx({
[`${prefix}--side-nav`]: true,
[`${prefix}--side-nav--expanded`]: isExpanded || isFocused,
[customClassName]: !!customClassName,
});

return (
<nav
className={`${prefix}--side-nav__navigation ${className}`}
{...accessibilityLabel}
onFocus={this.handleFocus}
onBlur={this.handleBlur}>
{children}
<SideNavFooter
assistiveText={assistiveText}
isExpanded={isExpanded}
onToggle={this.handleExpand}
/>
</nav>
);
}
}
export default SideNav;
8 changes: 4 additions & 4 deletions src/components/UIShell/SideNavFooter.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const { prefix } = settings;
const SideNavFooter = ({
assistiveText,
className: customClassName,
isExpanded,
expanded,
onToggle,
}) => {
const className = cx(`${prefix}--side-nav__footer`, customClassName);
Expand All @@ -31,10 +31,10 @@ const SideNavFooter = ({
<button
className={`${prefix}--side-nav__toggle`}
type="button"
onClick={onToggle}
onClick={evt => onToggle(evt)}
title={assistiveText}>
<div className={`${prefix}--side-nav__icon`}>
{isExpanded ? <Close20 /> : <ChevronRight20 />}
{expanded ? <Close20 /> : <ChevronRight20 />}
</div>
<span className={`${prefix}--assistive-text`}>{assistiveText}</span>
</button>
Expand All @@ -52,7 +52,7 @@ SideNavFooter.propTypes = {
/**
* Specify whether the side navigation is expanded or collapsed
*/
isExpanded: PropTypes.bool.isRequired,
expanded: PropTypes.bool.isRequired,

/**
* Provide a function that is called when the toggle button is interacted
Expand Down
33 changes: 0 additions & 33 deletions src/components/UIShell/__tests__/SideNav-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,37 +23,4 @@ describe('SideNav', () => {
const wrapper = mount(<SideNav {...mockProps} />);
expect(wrapper).toMatchSnapshot();
});

it('should toggle the menu expansion state when clicking on the footer', () => {
const wrapper = mount(<SideNav {...mockProps} />);
expect(wrapper.state('isExpanded')).toBe(false);
wrapper.find('button').simulate('click');
expect(wrapper.state('isExpanded')).toBe(true);
expect(wrapper).toMatchSnapshot();
});

it('should be expanded by default', () => {
const wrapper = mount(<SideNav {...mockProps} isExpanded />);
expect(wrapper.state('isExpanded')).toBe(true);
});

it('should be collapsed by default', () => {
const wrapper = mount(<SideNav {...mockProps} />);
expect(wrapper.state('isExpanded')).toBe(false);
});

it('Blur event should trigger a state update of isFocused', () => {
const wrapper = mount(<SideNav {...mockProps} />);
wrapper.simulate('focus');
expect(wrapper.state('isFocused')).toBe(true);
wrapper.simulate('blur');
expect(wrapper.state('isFocused')).toBe(false);
});

it('Focus event should trigger a state update of isFocused', () => {
const wrapper = mount(<SideNav {...mockProps} />);
expect(wrapper.state('isFocused')).toBe(false);
wrapper.simulate('focus');
expect(wrapper.state('isFocused')).toBe(true);
});
});
Loading

0 comments on commit 4e0e7f0

Please sign in to comment.