Skip to content

Commit

Permalink
feat(List): added keyboard navigation (#140)
Browse files Browse the repository at this point in the history
  • Loading branch information
igorarkhipenko committed Jul 18, 2019
1 parent 468305a commit 46f7d87
Show file tree
Hide file tree
Showing 9 changed files with 299 additions and 44 deletions.
93 changes: 85 additions & 8 deletions src/components/List/List.js
@@ -1,21 +1,92 @@
import React from 'react';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import bem from 'bem';
import ListItem from './ListItem';
import styles from './List.scss';
import { LIST_NAVIGATION_DIRECTIONS, ENTER_KEY } from '../../constants';

const { block, elem } = bem({
name: 'List',
classnames: styles,
propsToMods: ['isDivided']
});

const isListItem = element => element && element.type !== ListItem && element.type !== 'li';
const NAVIGATION_STEP_VALUES = {
[LIST_NAVIGATION_DIRECTIONS.UP]: -1,
[LIST_NAVIGATION_DIRECTIONS.DOWN]: 1
};

const List = React.forwardRef((props, ref) => {
const { children, isDivided, ...rest } = props;
const { children, isDivided, onNavigate, onSelect, ...rest } = props;
const [selectedIndex, setSelectedIndex] = useState(null);

const getNextSelectedIndex = keyCode => {
const stepValue = NAVIGATION_STEP_VALUES[keyCode];
const nextSelectedIndex = selectedIndex + stepValue;

// Return 0 index if nextSelectedIndex has negative value or selectedIndex hasn't been updated before
if (nextSelectedIndex < 0 || selectedIndex === null) {
return 0;
}

// Return last React.Children index if nextSelectedIndex is out of the right bound
if (nextSelectedIndex >= children.length) {
return children.length - 1;
}

// Return nextSelectedIndex without any changes for others cases
return nextSelectedIndex;
};

const handleKeyDown = e => {
// Update selectedIndex with arrow navigation and make onNavigate function callback
if (e.key === LIST_NAVIGATION_DIRECTIONS.UP || e.key === LIST_NAVIGATION_DIRECTIONS.DOWN) {
const nextSelectedIndex = getNextSelectedIndex(e.key);

if (selectedIndex !== nextSelectedIndex) {
e.preventDefault();
setSelectedIndex(nextSelectedIndex);

if (onNavigate) {
onNavigate(nextSelectedIndex, e.key);
}
}
}

// Imitate onClick event on Enter press and make onSelect function callback
if (e.key === ENTER_KEY) {
if (
children[selectedIndex] &&
children[selectedIndex].props &&
children[selectedIndex].props.onClick
) {
children[selectedIndex].props.onClick(e);

if (onSelect) {
onSelect(selectedIndex);
}
}
}
};

const handleMouseEnter = index => {
if (selectedIndex !== index) {
setSelectedIndex(index);
}
};

return (
<ul {...rest} ref={ref} {...block(props)}>
{React.Children.map(children, child =>
child ? React.cloneElement(child, elem('item', props)) : null
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex
<ul {...rest} ref={ref} tabIndex="0" onKeyDown={handleKeyDown} {...block(props)}>
{React.Children.map(children, (child, index) =>
child
? React.cloneElement(child, {
...elem('item', props),
isHighlighted: index === selectedIndex,
onMouseEnter: () => handleMouseEnter(index)
})
: null
)}
</ul>
);
Expand All @@ -30,7 +101,7 @@ List.propTypes = {

let error = null;
React.Children.forEach(prop, child => {
if (child && child.type !== ListItem && child.type !== 'li') {
if (isListItem(child)) {
error = new Error(
`'${componentName}' children should be of type 'ListItem' or 'li'.`
);
Expand All @@ -39,12 +110,18 @@ List.propTypes = {
return error;
},
/** Adds dividing lines between the list items */
isDivided: PropTypes.bool
isDivided: PropTypes.bool,
/** onNavigate function callback. (selectedIndex: number, key: 'ArrowUp' || 'ArrowDown') */
onNavigate: PropTypes.func,
/** onSelect function callback. (selectedIndex: number) */
onSelect: PropTypes.func
};

List.defaultProps = {
children: null,
isDivided: false
isDivided: false,
onNavigate: null,
onSelect: null
};

export default List;
4 changes: 4 additions & 0 deletions src/components/List/List.scss
Expand Up @@ -8,4 +8,8 @@
border-top: 1px solid var(--color-neutral-25);
}
}

&:focus {
outline: none;
}
}
15 changes: 13 additions & 2 deletions src/components/List/ListItem/ListItem.js
Expand Up @@ -8,11 +8,19 @@ import styles from './ListItem.scss';
const { block, elem } = bem({
name: 'ListItem',
classnames: styles,
propsToMods: ['isSelected', 'onClick', 'disabled', 'highlightContext']
propsToMods: ['isSelected', 'isHighlighted', 'onClick', 'disabled', 'highlightContext']
});

const ListItem = React.forwardRef((props, ref) => {
const { children, isSelected, onClick, disabled, highlightContext, ...rest } = props;
const {
children,
isSelected,
isHighlighted,
onClick,
disabled,
highlightContext,
...rest
} = props;
const customBlockMod = { clickable: typeof onClick === 'function' };

return (
Expand All @@ -35,6 +43,8 @@ ListItem.propTypes = {
onClick: PropTypes.func,
/** Formats this item as selected */
isSelected: PropTypes.bool,
/** Formats this item as selected */
isHighlighted: PropTypes.bool,
/** Format this item as disabled */
disabled: PropTypes.bool,
/** formatting context when hovered or selected */
Expand All @@ -45,6 +55,7 @@ ListItem.defaultProps = {
children: null,
onClick: null,
isSelected: false,
isHighlighted: false,
disabled: false,
highlightContext: 'default'
};
Expand Down
11 changes: 8 additions & 3 deletions src/components/List/ListItem/ListItem.scss
Expand Up @@ -10,10 +10,10 @@ $contexts: (neutral, brand, primary, accent, info, good, warning, bad);
}

&:hover:not(.ListItem--disabled) {
background-color: var(--color-hover-background);
background-color: var(--color-highlight-background);
}

&--isSelected {
&--isSelected, &--isHighlighted {
background-color: var(--color-highlight-background);
}

Expand All @@ -28,10 +28,15 @@ $contexts: (neutral, brand, primary, accent, info, good, warning, bad);
color: var(--color-background);
}

&_#{ $context } + .ListItem--isSelected {
&_#{ $context }.ListItem--isSelected {
background-color: var(--color-#{$context});
color: var(--color-background);
}

&_#{ $context }.ListItem--isHighlighted:not(.ListItem--disabled) {
background-color: var(--color-#{$context});
color: var(--color-highlight-background)
}
}
}
}
Expand Up @@ -4,6 +4,7 @@ exports[`ListItem component should render ListItem correctly 1`] = `
<ListItem
disabled={false}
highlightContext="default"
isHighlighted={false}
isSelected={false}
onClick={null}
>
Expand Down

0 comments on commit 46f7d87

Please sign in to comment.