Skip to content

Commit

Permalink
feat: usePopper (#299)
Browse files Browse the repository at this point in the history
* feat: usePopper

* feat: usePopper

* Remove react-popper

* Remove explicit ref from dependencies list

* address feedback

* move hook upstream

* rm useless tests

* Apply suggestions from code review

Co-Authored-By: Jimmy Jia <tesrin@gmail.com>
  • Loading branch information
jquense and taion committed Aug 15, 2019
1 parent 23f9211 commit bb5c51f
Show file tree
Hide file tree
Showing 27 changed files with 1,378 additions and 634 deletions.
18 changes: 9 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,10 @@
},
"dependencies": {
"@babel/runtime": "^7.4.5",
"@restart/hooks": "^0.3.2",
"@restart/hooks": "^0.3.12",
"dom-helpers": "^3.4.0",
"popper.js": "^1.15.0",
"prop-types": "^15.7.2",
"react-popper": "^1.3.3",
"uncontrollable": "^7.0.0",
"warning": "^4.0.3"
},
Expand All @@ -77,7 +77,7 @@
"@babel/polyfill": "^7.4.4",
"@babel/preset-env": "^7.5.4",
"@babel/preset-react": "^7.0.0",
"@emotion/core": "^10.0.14",
"@emotion/core": "^10.0.15",
"@react-bootstrap/eslint-config": "^1.2.0",
"babel-eslint": "^10.0.2",
"babel-plugin-add-module-exports": "^1.0.2",
Expand All @@ -94,10 +94,10 @@
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-mocha": "^6.0.0",
"eslint-plugin-prettier": "^3.1.0",
"eslint-plugin-react": "^7.14.2",
"eslint-plugin-react": "^7.14.3",
"eslint-plugin-react-hooks": "^1.6.1",
"gh-pages": "^2.0.1",
"husky": "^3.0.0",
"gh-pages": "^2.1.0",
"husky": "^3.0.2",
"jquery": "^3.4.1",
"karma": "^4.2.0",
"karma-chrome-launcher": "^3.0.0",
Expand All @@ -107,19 +107,19 @@
"karma-sinon-chai": "^2.0.2",
"karma-sourcemap-loader": "^0.3.7",
"karma-webpack": "4.0.2",
"lint-staged": "^9.2.0",
"lint-staged": "^9.2.1",
"lodash": "^4.17.14",
"mocha": "^6.1.4",
"prettier": "^1.18.2",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react-live": "^2.1.2",
"react-transition-group": "^4.2.1",
"react-transition-group": "^4.2.2",
"rimraf": "^3.0.0",
"simulant": "^0.2.2",
"sinon": "^7.3.2",
"sinon-chai": "^3.3.0",
"webpack": "^4.35.3",
"webpack": "^4.39.1",
"webpack-atoms": "^11.0.4",
"webpack-cli": "^3.3.6"
}
Expand Down
269 changes: 139 additions & 130 deletions src/Dropdown.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import matches from 'dom-helpers/query/matches';
import qsa from 'dom-helpers/query/querySelectorAll';
import React from 'react';
import ReactDOM from 'react-dom';
import React, { useCallback, useRef, useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';
import { uncontrollable } from 'uncontrollable';
import { useUncontrolled } from 'uncontrollable';
import usePrevious from '@restart/hooks/usePrevious';
import useCallbackRef from '@restart/hooks/useCallbackRef';
import useForceUpdate from '@restart/hooks/useForceUpdate';
import useEventCallback from '@restart/hooks/useEventCallback';

import * as Popper from 'react-popper';
import DropdownContext from './DropdownContext';
import DropdownMenu from './DropdownMenu';
import DropdownToggle from './DropdownToggle';
Expand Down Expand Up @@ -58,6 +60,11 @@ const propTypes = {
*/
show: PropTypes.bool,

/**
* Sets the initial show position of the Dropdown.
*/
defaultShow: PropTypes.bool,

/**
* A callback fired when the Dropdown wishes to change visibility. Called with the requested
* `show` value, the DOM event, and the source that fired it: `'click'`,`'keydown'`,`'rootClose'`, or `'select'`.
Expand Down Expand Up @@ -88,82 +95,130 @@ const defaultProps = {
* - `Dropdown.Toggle` generally a button that triggers the menu opening
* - `Dropdown.Menu` The overlaid, menu, positioned to the toggle with PopperJs
*/
class Dropdown extends React.Component {
static displayName = 'ReactOverlaysDropdown';

static getDerivedStateFromProps({ drop, alignEnd, show }, prevState) {
const lastShow = prevState.context.show;
return {
lastShow,
context: {
...prevState.context,
drop,
show,
alignEnd,
},
};
function Dropdown({
drop,
alignEnd,
defaultShow,
show: rawShow,
onToggle: rawOnToggle,
itemSelector,
focusFirstItemOnShow,
children,
}) {
const forceUpdate = useForceUpdate();
const { show, onToggle } = useUncontrolled(
{ defaultShow, show: rawShow, onToggle: rawOnToggle },
{ show: 'onToggle' },
);

const [toggleElement, setToggle] = useCallbackRef();

// We use normal refs instead of useCallbackRef in order to populate the
// the value as quickly as possible, otherwise the effect to focus the element
// may run before the state value is set
const menuRef = useRef();
const menuElement = menuRef.current;

const setMenu = useCallback(
ref => {
menuRef.current = ref;
// ensure that a menu set triggers an update for consumers
forceUpdate();
},
[forceUpdate],
);

const lastShow = usePrevious(show);
const lastSourceEvent = useRef(null);
const focusInDropdown = useRef(false);

const toggle = useCallback(
event => {
onToggle(!show, event);
},
[onToggle, show],
);

const context = useMemo(
() => ({
toggle,
drop,
show,
alignEnd,
menuElement,
toggleElement,
setMenu,
setToggle,
}),
[
toggle,
drop,
show,
alignEnd,
menuElement,
toggleElement,
setMenu,
setToggle,
],
);

if (menuElement && lastShow && !show) {
focusInDropdown.current = menuElement.contains(document.activeElement);
}

constructor(...args) {
super(...args);

this._focusInDropdown = false;

this.menu = null;

this.state = {
context: {
close: this.handleClose,
toggle: this.handleClick,
menuRef: r => {
this.menu = r;
},
toggleRef: r => {
const toggleNode = r && ReactDOM.findDOMNode(r);
this.setState(({ context }) => ({
context: { ...context, toggleNode },
}));
},
},
};
}
const focusToggle = useEventCallback(() => {
if (toggleElement && toggleElement.focus) {
toggleElement.focus();
}
});

componentDidUpdate(prevProps) {
const { show } = this.props;
const prevOpen = prevProps.show;
const maybeFocusFirst = useEventCallback(() => {
const type = lastSourceEvent.current;
let focusType = focusFirstItemOnShow;

if (show && !prevOpen) {
this.maybeFocusFirst();
if (focusType == null) {
focusType =
menuRef.current && matches(menuRef.current, '[role=menu]')
? 'keyboard'
: false;
}
this._lastSourceEvent = null;

if (!show && prevOpen) {
// if focus hasn't already moved from the menu let's return it
// to the toggle
if (this._focusInDropdown) {
this._focusInDropdown = false;
this.focus();
}

if (
focusType === false ||
(focusType === 'keyboard' && !/^key.+$/.test(type))
) {
return;
}
}

getNextFocusedChild(current, offset) {
if (!this.menu) return null;
let first = qsa(menuRef.current, itemSelector)[0];
if (first && first.focus) first.focus();
});

useEffect(() => {
if (show) maybeFocusFirst();
else if (focusInDropdown.current) {
focusInDropdown.current = false;
focusToggle();
}
// only `show` should be changing
}, [show, focusInDropdown, focusToggle, maybeFocusFirst]);

const { itemSelector } = this.props;
let items = qsa(this.menu, itemSelector);
useEffect(() => {
lastSourceEvent.current = null;
});

const getNextFocusedChild = (current, offset) => {
if (!menuRef.current) return null;

let items = qsa(menuRef.current, itemSelector);

let index = items.indexOf(current) + offset;
index = Math.max(0, Math.min(index, items.length));

return items[index];
}

handleClick = event => {
this.toggleOpen(event);
};

handleKeyDown = event => {
const handleKeyDown = event => {
const { key, target } = event;

// Second only to https://github.com/twbs/bootstrap/blob/8cfbf6933b8a0146ac3fbc369f19e520bd1ebdac/js/src/dropdown.js#L400
Expand All @@ -172,99 +227,53 @@ class Dropdown extends React.Component {
if (
isInput &&
(key === ' ' ||
(key !== 'Escape' && this.menu && this.menu.contains(target)))
(key !== 'Escape' &&
menuRef.current &&
menuRef.current.contains(target)))
) {
return;
}

this._lastSourceEvent = event.type;
lastSourceEvent.current = event.type;

switch (key) {
case 'ArrowUp': {
let next = this.getNextFocusedChild(target, -1);
let next = getNextFocusedChild(target, -1);
if (next && next.focus) next.focus();
event.preventDefault();

return;
}
case 'ArrowDown':
event.preventDefault();
if (!this.props.show) {
this.toggleOpen(event);
if (!show) {
toggle(event);
} else {
let next = this.getNextFocusedChild(target, 1);
let next = getNextFocusedChild(target, 1);
if (next && next.focus) next.focus();
}
return;
case 'Escape':
case 'Tab':
this.props.onToggle(false, event);
onToggle(false, event);
break;
default:
}
};

hasMenuRole() {
return this.menu && matches(this.menu, '[role=menu]');
}

focus() {
const { toggleNode } = this.state.context;
if (toggleNode && toggleNode.focus) {
toggleNode.focus();
}
}

maybeFocusFirst() {
const type = this._lastSourceEvent;
let { focusFirstItemOnShow } = this.props;
if (focusFirstItemOnShow == null) {
focusFirstItemOnShow = this.hasMenuRole() ? 'keyboard' : false;
}

if (
focusFirstItemOnShow === false ||
(focusFirstItemOnShow === 'keyboard' && !/^key.+$/.test(type))
) {
return;
}

const { itemSelector } = this.props;
let first = qsa(this.menu, itemSelector)[0];
if (first && first.focus) first.focus();
}

toggleOpen(event) {
let show = !this.props.show;

this.props.onToggle(show, event);
}

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

delete props.onToggle;

if (this.menu && this.state.lastShow && !this.props.show) {
this._focusInDropdown = this.menu.contains(document.activeElement);
}

return (
<DropdownContext.Provider value={this.state.context}>
<Popper.Manager>
{children({ props: { onKeyDown: this.handleKeyDown } })}
</Popper.Manager>
</DropdownContext.Provider>
);
}
return (
<DropdownContext.Provider value={context}>
{children({ props: { onKeyDown: handleKeyDown } })}
</DropdownContext.Provider>
);
}

Dropdown.displayName = 'ReactOverlaysDropdown';

Dropdown.propTypes = propTypes;
Dropdown.defaultProps = defaultProps;

const UncontrolledDropdown = uncontrollable(Dropdown, { show: 'onToggle' });

UncontrolledDropdown.Menu = DropdownMenu;
UncontrolledDropdown.Toggle = DropdownToggle;
Dropdown.Menu = DropdownMenu;
Dropdown.Toggle = DropdownToggle;

export default UncontrolledDropdown;
export default Dropdown;
Loading

0 comments on commit bb5c51f

Please sign in to comment.