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 1 commit
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
"@restart/hooks": "^0.2.12",
"classnames": "^2.2.6",
"dom-helpers": "^3.4.0",
"popper.js": "^1.15.0",
"prop-types": "^15.7.2",
"prop-types-extra": "^1.1.0",
"react-popper": "^1.3.3",
jquense marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
248 changes: 117 additions & 131 deletions src/Dropdown.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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/hook';
Copy link
Contributor

Choose a reason for hiding this comment

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

Upgrade uncontrollable

import useCallbackRef from '@restart/hooks/useCallbackRef';
import usePrevious from '@restart/hooks/usePrevious';
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 +59,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 +94,110 @@ 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 { show, onToggle } = useUncontrolled(
{ defaultShow, show: rawShow, onToggle: rawOnToggle },
{ show: 'onToggle' },
);
const [toggleElement, attachToggle] = useCallbackRef();
const [menuElement, attachMenu] = useCallbackRef();

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: attachMenu,
setToggle: attachToggle,
}),
[
alignEnd,
attachMenu,
attachToggle,
drop,
menuElement,
show,
toggle,
toggleElement,
],
);

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 },
}));
},
},
};
}

componentDidUpdate(prevProps) {
const { show } = this.props;
const prevOpen = prevProps.show;
const focus = useEventCallback(() => {
if (toggleElement && toggleElement.focus) {
toggleElement.focus();
}
});

const maybeFocusFirst = useEventCallback(() => {
const type = lastSourceEvent.current;
let focusStype = focusFirstItemOnShow;
jquense marked this conversation as resolved.
Show resolved Hide resolved
if (focusStype == null) {
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 useMemo this outside of this callback? I don't think this needs to be calculated on-the-fly.

Copy link
Member Author

Choose a reason for hiding this comment

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

in practice it only runs on show so it's probably fine

focusStype =
menuElement && matches(menuElement, '[role=menu]') ? 'keyboard' : false;
}

if (show && !prevOpen) {
this.maybeFocusFirst();
if (
focusStype === false ||
(focusStype === 'keyboard' && !/^key.+$/.test(type))
) {
return;
}
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();
}

let first = qsa(menuElement, itemSelector)[0];
if (first && first.focus) first.focus();
});

useEffect(() => {
if (show) maybeFocusFirst();
else if (focusInDropdown.current) {
focusInDropdown.current = false;
}
}
// 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, focus, maybeFocusFirst]);

useEffect(() => {
lastSourceEvent.current = null;
});

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

const { itemSelector } = this.props;
let items = qsa(this.menu, itemSelector);
let items = qsa(menuElement, 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 +206,51 @@ class Dropdown extends React.Component {
if (
isInput &&
(key === ' ' ||
(key !== 'Escape' && this.menu && this.menu.contains(target)))
(key !== 'Escape' && menuElement && menuElement.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