Skip to content

Commit

Permalink
Dropdown improvements (#70)
Browse files Browse the repository at this point in the history
* adding dropdown content resize observer
* refactoring Dropdown and Switch prop types and default props
* custom key codes for Dropdown close and DropdownList select events
* prevent default on item select with keydown event
* adding autofocus first item to DropdownList component
  • Loading branch information
kamilmateusiak committed May 23, 2019
1 parent 864a477 commit 4bbe7f3
Show file tree
Hide file tree
Showing 9 changed files with 220 additions and 77 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@
"react-day-picker": "^7.2.4",
"react-material-icon-svg": "1.7.0",
"react-popper": "^1.3.3",
"react-transition-group": "^2.4.0"
"react-transition-group": "^2.4.0",
"resize-observer-polyfill": "^1.5.1"
},
"postcss": {},
"jest": {
Expand Down
154 changes: 111 additions & 43 deletions src/components/Dropdown/Dropdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,14 @@ import * as PropTypes from 'prop-types';
import memoizeOne from 'memoize-one';
import cssClassNames from 'classnames/bind';
import { Manager, Reference, Popper } from 'react-popper';
import ResizeObserver from 'resize-observer-polyfill';
import styles from './style.scss';
import getMergedClassNames from '../../utils/getMergedClassNames';
import { KeyCodes } from '../../constants/keyCodes';

const cx = cssClassNames.bind(styles);

class Dropdown extends React.PureComponent {
static defaultProps = {
modifiers: {},
zIndex: 20,
closeOnEscPress: true,
closeOnEnterPress: false
};

static buildPopperModifiers(modifiers) {
const { offset, flip, hide, preventOverflow, arrow, ...rest } = modifiers;
return {
Expand All @@ -40,6 +34,7 @@ class Dropdown extends React.PureComponent {
componentDidMount() {
if (this.props.isVisible) {
this.addEventHandlers();
this.attachResizeObserver();
}
}

Expand All @@ -49,18 +44,21 @@ class Dropdown extends React.PureComponent {

if (isShown) {
this.addEventHandlers();
this.attachResizeObserver();
if (this.popupRef) {
this.popupRef.focus({ preventScroll: true });
}
}

if (isHidden) {
this.removeEventHandlers();
this.detachResizeObserver();
}
}

componentWillUnmount() {
this.removeEventHandlers();
this.detachResizeObserver();
}

getModifiers = memoizeOne(Dropdown.buildPopperModifiers);
Expand Down Expand Up @@ -88,22 +86,51 @@ class Dropdown extends React.PureComponent {
};

handleKeyDown = event => {
if (this.props.onClose) {
const isEscKeyPressed = event.keyCode === KeyCodes.esc;
const isEnterKeyPressed = event.keyCode === KeyCodes.enter;
const { keyCode } = event;
const {
closeKeyCodes,
closeOnEnterPress,
closeOnEscPress,
onClose
} = this.props;

if (onClose) {
const isEscKeyPressed = keyCode === KeyCodes.esc;
const isEnterKeyPressed = keyCode === KeyCodes.enter;
const isCustomCloseKeyPressed =
closeKeyCodes && closeKeyCodes.includes(keyCode);

if (
(this.props.closeOnEscPress && isEscKeyPressed) ||
(this.props.closeOnEnterPress && isEnterKeyPressed)
(closeOnEscPress && isEscKeyPressed) ||
(closeOnEnterPress && isEnterKeyPressed) ||
isCustomCloseKeyPressed
) {
this.props.onClose();
onClose();
if (this.triggerRef) {
this.triggerRef.focus();
}
}
}
};

attachResizeObserver = () => {
// to boost component performance resize observer should be optional
if (this.props.shouldUpdateOnResize && this.popupRef) {
this.observer = new ResizeObserver(() => {
if (this.popperScheduleUpdate) {
this.popperScheduleUpdate();
}
});
this.observer.observe(this.popupRef);
}
};

detachResizeObserver = () => {
if (this.observer) {
this.observer.disconnect();
}
};

addEventHandlers = () => {
document.addEventListener('keydown', this.handleKeyDown, true);
document.addEventListener('click', this.handleDocumentClick);
Expand All @@ -114,9 +141,14 @@ class Dropdown extends React.PureComponent {
document.removeEventListener('click', this.handleDocumentClick);
};

render() {
const { children, className, triggerRenderer, isVisible } = this.props;

renderDropdownContent = ({
ref,
style,
placement,
arrowProps,
scheduleUpdate
}) => {
const { className, isVisible, zIndex, children, modifiers } = this.props;
const mergedClassNames = getMergedClassNames(
cx({
dropdown: true,
Expand All @@ -125,41 +157,59 @@ class Dropdown extends React.PureComponent {
className
);

const modifiers = this.getModifiers(this.props.modifiers);
const computedModifiers = this.getModifiers(modifiers);

// updating `popperScheduleUpdate` reference used in resize observer
this.popperScheduleUpdate = scheduleUpdate;

return (
<div
ref={ref}
tabIndex={0}
style={{ ...style, zIndex }}
data-placement={placement}
className={mergedClassNames}
>
{children}
{computedModifiers.arrow.enabled && (
<div
ref={arrowProps.ref}
className={styles.dropdown__arrow}
data-placement={placement}
style={arrowProps.style}
/>
)}
</div>
);
};

render() {
const {
placement,
triggerRenderer,
eventsEnabled,
positionFixed,
referenceElement,
isVisible
} = this.props;

const computedModifiers = this.getModifiers(this.props.modifiers);

return (
<Manager>
{triggerRenderer && (
<Reference innerRef={this.setTriggerRef}>{triggerRenderer}</Reference>
)}
{this.props.isVisible && (
{isVisible && (
<Popper
innerRef={this.setPopupRef}
placement={this.props.placement || 'bottom-start'}
modifiers={modifiers}
eventsEnabled={this.props.eventsEnabled}
positionFixed={this.props.positionFixed}
referenceElement={this.props.referenceElement}
placement={placement}
modifiers={computedModifiers}
eventsEnabled={eventsEnabled}
positionFixed={positionFixed}
referenceElement={referenceElement}
>
{({ ref, style, placement, arrowProps }) => (
<div
ref={ref}
tabIndex={0}
style={{ ...style, zIndex: this.props.zIndex }}
data-placement={placement}
className={mergedClassNames}
>
{children}
{modifiers.arrow.enabled && (
<div
ref={arrowProps.ref}
className={styles.dropdown__arrow}
data-placement={placement}
style={arrowProps.style}
/>
)}
</div>
)}
{this.renderDropdownContent}
</Popper>
)}
</Manager>
Expand All @@ -168,10 +218,14 @@ class Dropdown extends React.PureComponent {
}

Dropdown.propTypes = {
children: PropTypes.node.isRequired,
children: PropTypes.node,
className: PropTypes.string,
closeOnEscPress: PropTypes.bool,
closeOnEnterPress: PropTypes.bool,
/**
* you can specify which key press should trigger Dropdown close
*/
closeKeyCodes: PropTypes.arrayOf(PropTypes.number),
eventsEnabled: PropTypes.bool,
isVisible: PropTypes.bool.isRequired,
modifiers: PropTypes.object,
Expand All @@ -197,9 +251,23 @@ Dropdown.propTypes = {
clientWidth: PropTypes.number.isRequired,
clientHeight: PropTypes.number.isRequired
}),
/**
* Pass `true` when it's possible that content of your dropdown will resize
* (e.g removing list items on select)
*/
shouldUpdateOnResize: PropTypes.bool,
triggerRenderer: PropTypes.func,
zIndex: PropTypes.number,
onClose: PropTypes.func
};

Dropdown.defaultProps = {
modifiers: {},
zIndex: 20,
closeOnEscPress: true,
closeOnEnterPress: false,
placement: 'bottom-start',
shouldUpdateOnResize: false
};

export default Dropdown;
70 changes: 62 additions & 8 deletions src/components/Dropdown/DropdownList.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,27 @@ import findNextFocusableItem from '../../helpers/find-next-focusable-item';
const baseClass = 'dropdown';

class DropdownList extends React.PureComponent {
state = {
focusedElement: null
};
constructor(props) {
super(props);

this.state = {
focusedElement: this.getFirstFocusableItemId(),
itemsCount: props.items.length
};
}

static getDerivedStateFromProps(props, state) {
if (
props.autoFocusOnItemsCountChange &&
props.items.length !== state.itemsCount
) {
return {
focusedElement: this.getFirstFocusableItemId(),
itemsCount: props.items.length
};
}
return null;
}

componentDidMount() {
document.addEventListener('keydown', this.onKeydown);
Expand All @@ -31,8 +49,9 @@ class DropdownList extends React.PureComponent {
this.handleArrowKeyUse(event);
}

if (keyCode === KeyCodes.enter) {
this.handleEnterKeyUse();
if (this.isItemSelectKeyCode(keyCode)) {
event.preventDefault();
this.handleSelectKeyUse();
}
};

Expand All @@ -48,7 +67,16 @@ class DropdownList extends React.PureComponent {
return this.hoverCallbacks[itemKey];
};

handleEnterKeyUse = () => {
getFirstFocusableItemId = () => {
const focusableItem = this.props.items.find(item => !item.isDisabled);

if (!focusableItem) {
return null;
}
return focusableItem.itemId;
};

handleSelectKeyUse = () => {
const { focusedElement } = this.state;

if (focusedElement !== null) {
Expand Down Expand Up @@ -104,6 +132,16 @@ class DropdownList extends React.PureComponent {
);
};

isItemSelectKeyCode = keyCode => {
const { itemSelectKeyCodes } = this.props;

if (itemSelectKeyCodes && itemSelectKeyCodes.includes(keyCode)) {
return true;
}

return false;
};

scrollItems = () => {
if (!this.listRef.current) {
return;
Expand Down Expand Up @@ -142,7 +180,14 @@ class DropdownList extends React.PureComponent {
listRef = React.createRef();

render() {
const { className, items, getItemBody, ...restProps } = this.props;
const {
className,
items,
getItemBody,
itemSelectKeyCodes,
autoFocusOnItemsCountChange,
...restProps
} = this.props;

const mergedClassNames = getMergedClassNames(
styles[`${baseClass}__list`],
Expand Down Expand Up @@ -188,6 +233,7 @@ class DropdownList extends React.PureComponent {
}

DropdownList.propTypes = {
autoFocusOnItemsCountChange: PropTypes.bool,
className: PropTypes.string,
items: PropTypes.arrayOf(
PropTypes.shape({
Expand All @@ -205,7 +251,15 @@ DropdownList.propTypes = {
})
).isRequired,
getItemBody: PropTypes.func,
onScroll: PropTypes.func
onScroll: PropTypes.func,
/**
* you can specify which key press should trigger list item select
*/
itemSelectKeyCodes: PropTypes.arrayOf(PropTypes.number)
};

DropdownList.defaultProps = {
itemSelectKeyCodes: [KeyCodes.enter]
};

export default DropdownList;
4 changes: 2 additions & 2 deletions src/components/Dropdown/DropdownList.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ const generateItems = (length = 10) =>
onItemSelect: jest.fn()
}));

describe('Archives | Components | FiltersMenu', () => {
describe('Components | DropdownList', () => {
let items;

beforeEach(() => {
items = generateItems(4);
});

it('renders correctly', () => {
it('renders correctly four items with autofocused second item', () => {
const renderer = new ShallowRenderer();
const component = renderer.render(<DropdownList items={items} />);

Expand Down
Loading

0 comments on commit 4bbe7f3

Please sign in to comment.