Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use popper #299

Merged
merged 11 commits into from
Aug 15, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to guard and check the previous value of show anyway?

}, [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