Skip to content

Commit

Permalink
feat: usePopper
Browse files Browse the repository at this point in the history
  • Loading branch information
jquense committed Jun 14, 2019
1 parent 9f98306 commit 6cf29e9
Show file tree
Hide file tree
Showing 24 changed files with 1,302 additions and 470 deletions.
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",
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';
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;
if (focusStype == null) {
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
}, [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

0 comments on commit 6cf29e9

Please sign in to comment.