Skip to content

Commit bb5c51f

Browse files
jquensetaion
andauthored
feat: usePopper (#299)
* 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>
1 parent 23f9211 commit bb5c51f

27 files changed

+1378
-634
lines changed

package.json

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,10 @@
5757
},
5858
"dependencies": {
5959
"@babel/runtime": "^7.4.5",
60-
"@restart/hooks": "^0.3.2",
60+
"@restart/hooks": "^0.3.12",
6161
"dom-helpers": "^3.4.0",
62+
"popper.js": "^1.15.0",
6263
"prop-types": "^15.7.2",
63-
"react-popper": "^1.3.3",
6464
"uncontrollable": "^7.0.0",
6565
"warning": "^4.0.3"
6666
},
@@ -77,7 +77,7 @@
7777
"@babel/polyfill": "^7.4.4",
7878
"@babel/preset-env": "^7.5.4",
7979
"@babel/preset-react": "^7.0.0",
80-
"@emotion/core": "^10.0.14",
80+
"@emotion/core": "^10.0.15",
8181
"@react-bootstrap/eslint-config": "^1.2.0",
8282
"babel-eslint": "^10.0.2",
8383
"babel-plugin-add-module-exports": "^1.0.2",
@@ -94,10 +94,10 @@
9494
"eslint-plugin-jsx-a11y": "^6.2.3",
9595
"eslint-plugin-mocha": "^6.0.0",
9696
"eslint-plugin-prettier": "^3.1.0",
97-
"eslint-plugin-react": "^7.14.2",
97+
"eslint-plugin-react": "^7.14.3",
9898
"eslint-plugin-react-hooks": "^1.6.1",
99-
"gh-pages": "^2.0.1",
100-
"husky": "^3.0.0",
99+
"gh-pages": "^2.1.0",
100+
"husky": "^3.0.2",
101101
"jquery": "^3.4.1",
102102
"karma": "^4.2.0",
103103
"karma-chrome-launcher": "^3.0.0",
@@ -107,19 +107,19 @@
107107
"karma-sinon-chai": "^2.0.2",
108108
"karma-sourcemap-loader": "^0.3.7",
109109
"karma-webpack": "4.0.2",
110-
"lint-staged": "^9.2.0",
110+
"lint-staged": "^9.2.1",
111111
"lodash": "^4.17.14",
112112
"mocha": "^6.1.4",
113113
"prettier": "^1.18.2",
114114
"react": "^16.8.6",
115115
"react-dom": "^16.8.6",
116116
"react-live": "^2.1.2",
117-
"react-transition-group": "^4.2.1",
117+
"react-transition-group": "^4.2.2",
118118
"rimraf": "^3.0.0",
119119
"simulant": "^0.2.2",
120120
"sinon": "^7.3.2",
121121
"sinon-chai": "^3.3.0",
122-
"webpack": "^4.35.3",
122+
"webpack": "^4.39.1",
123123
"webpack-atoms": "^11.0.4",
124124
"webpack-cli": "^3.3.6"
125125
}

src/Dropdown.js

Lines changed: 139 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import matches from 'dom-helpers/query/matches';
22
import qsa from 'dom-helpers/query/querySelectorAll';
3-
import React from 'react';
4-
import ReactDOM from 'react-dom';
3+
import React, { useCallback, useRef, useEffect, useMemo } from 'react';
54
import PropTypes from 'prop-types';
6-
import { uncontrollable } from 'uncontrollable';
5+
import { useUncontrolled } from 'uncontrollable';
6+
import usePrevious from '@restart/hooks/usePrevious';
7+
import useCallbackRef from '@restart/hooks/useCallbackRef';
8+
import useForceUpdate from '@restart/hooks/useForceUpdate';
9+
import useEventCallback from '@restart/hooks/useEventCallback';
710

8-
import * as Popper from 'react-popper';
911
import DropdownContext from './DropdownContext';
1012
import DropdownMenu from './DropdownMenu';
1113
import DropdownToggle from './DropdownToggle';
@@ -58,6 +60,11 @@ const propTypes = {
5860
*/
5961
show: PropTypes.bool,
6062

63+
/**
64+
* Sets the initial show position of the Dropdown.
65+
*/
66+
defaultShow: PropTypes.bool,
67+
6168
/**
6269
* A callback fired when the Dropdown wishes to change visibility. Called with the requested
6370
* `show` value, the DOM event, and the source that fired it: `'click'`,`'keydown'`,`'rootClose'`, or `'select'`.
@@ -88,82 +95,130 @@ const defaultProps = {
8895
* - `Dropdown.Toggle` generally a button that triggers the menu opening
8996
* - `Dropdown.Menu` The overlaid, menu, positioned to the toggle with PopperJs
9097
*/
91-
class Dropdown extends React.Component {
92-
static displayName = 'ReactOverlaysDropdown';
93-
94-
static getDerivedStateFromProps({ drop, alignEnd, show }, prevState) {
95-
const lastShow = prevState.context.show;
96-
return {
97-
lastShow,
98-
context: {
99-
...prevState.context,
100-
drop,
101-
show,
102-
alignEnd,
103-
},
104-
};
98+
function Dropdown({
99+
drop,
100+
alignEnd,
101+
defaultShow,
102+
show: rawShow,
103+
onToggle: rawOnToggle,
104+
itemSelector,
105+
focusFirstItemOnShow,
106+
children,
107+
}) {
108+
const forceUpdate = useForceUpdate();
109+
const { show, onToggle } = useUncontrolled(
110+
{ defaultShow, show: rawShow, onToggle: rawOnToggle },
111+
{ show: 'onToggle' },
112+
);
113+
114+
const [toggleElement, setToggle] = useCallbackRef();
115+
116+
// We use normal refs instead of useCallbackRef in order to populate the
117+
// the value as quickly as possible, otherwise the effect to focus the element
118+
// may run before the state value is set
119+
const menuRef = useRef();
120+
const menuElement = menuRef.current;
121+
122+
const setMenu = useCallback(
123+
ref => {
124+
menuRef.current = ref;
125+
// ensure that a menu set triggers an update for consumers
126+
forceUpdate();
127+
},
128+
[forceUpdate],
129+
);
130+
131+
const lastShow = usePrevious(show);
132+
const lastSourceEvent = useRef(null);
133+
const focusInDropdown = useRef(false);
134+
135+
const toggle = useCallback(
136+
event => {
137+
onToggle(!show, event);
138+
},
139+
[onToggle, show],
140+
);
141+
142+
const context = useMemo(
143+
() => ({
144+
toggle,
145+
drop,
146+
show,
147+
alignEnd,
148+
menuElement,
149+
toggleElement,
150+
setMenu,
151+
setToggle,
152+
}),
153+
[
154+
toggle,
155+
drop,
156+
show,
157+
alignEnd,
158+
menuElement,
159+
toggleElement,
160+
setMenu,
161+
setToggle,
162+
],
163+
);
164+
165+
if (menuElement && lastShow && !show) {
166+
focusInDropdown.current = menuElement.contains(document.activeElement);
105167
}
106168

107-
constructor(...args) {
108-
super(...args);
109-
110-
this._focusInDropdown = false;
111-
112-
this.menu = null;
113-
114-
this.state = {
115-
context: {
116-
close: this.handleClose,
117-
toggle: this.handleClick,
118-
menuRef: r => {
119-
this.menu = r;
120-
},
121-
toggleRef: r => {
122-
const toggleNode = r && ReactDOM.findDOMNode(r);
123-
this.setState(({ context }) => ({
124-
context: { ...context, toggleNode },
125-
}));
126-
},
127-
},
128-
};
129-
}
169+
const focusToggle = useEventCallback(() => {
170+
if (toggleElement && toggleElement.focus) {
171+
toggleElement.focus();
172+
}
173+
});
130174

131-
componentDidUpdate(prevProps) {
132-
const { show } = this.props;
133-
const prevOpen = prevProps.show;
175+
const maybeFocusFirst = useEventCallback(() => {
176+
const type = lastSourceEvent.current;
177+
let focusType = focusFirstItemOnShow;
134178

135-
if (show && !prevOpen) {
136-
this.maybeFocusFirst();
179+
if (focusType == null) {
180+
focusType =
181+
menuRef.current && matches(menuRef.current, '[role=menu]')
182+
? 'keyboard'
183+
: false;
137184
}
138-
this._lastSourceEvent = null;
139-
140-
if (!show && prevOpen) {
141-
// if focus hasn't already moved from the menu let's return it
142-
// to the toggle
143-
if (this._focusInDropdown) {
144-
this._focusInDropdown = false;
145-
this.focus();
146-
}
185+
186+
if (
187+
focusType === false ||
188+
(focusType === 'keyboard' && !/^key.+$/.test(type))
189+
) {
190+
return;
147191
}
148-
}
149192

150-
getNextFocusedChild(current, offset) {
151-
if (!this.menu) return null;
193+
let first = qsa(menuRef.current, itemSelector)[0];
194+
if (first && first.focus) first.focus();
195+
});
196+
197+
useEffect(() => {
198+
if (show) maybeFocusFirst();
199+
else if (focusInDropdown.current) {
200+
focusInDropdown.current = false;
201+
focusToggle();
202+
}
203+
// only `show` should be changing
204+
}, [show, focusInDropdown, focusToggle, maybeFocusFirst]);
152205

153-
const { itemSelector } = this.props;
154-
let items = qsa(this.menu, itemSelector);
206+
useEffect(() => {
207+
lastSourceEvent.current = null;
208+
});
209+
210+
const getNextFocusedChild = (current, offset) => {
211+
if (!menuRef.current) return null;
212+
213+
let items = qsa(menuRef.current, itemSelector);
155214

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

159218
return items[index];
160-
}
161-
162-
handleClick = event => {
163-
this.toggleOpen(event);
164219
};
165220

166-
handleKeyDown = event => {
221+
const handleKeyDown = event => {
167222
const { key, target } = event;
168223

169224
// Second only to https://github.com/twbs/bootstrap/blob/8cfbf6933b8a0146ac3fbc369f19e520bd1ebdac/js/src/dropdown.js#L400
@@ -172,99 +227,53 @@ class Dropdown extends React.Component {
172227
if (
173228
isInput &&
174229
(key === ' ' ||
175-
(key !== 'Escape' && this.menu && this.menu.contains(target)))
230+
(key !== 'Escape' &&
231+
menuRef.current &&
232+
menuRef.current.contains(target)))
176233
) {
177234
return;
178235
}
179236

180-
this._lastSourceEvent = event.type;
237+
lastSourceEvent.current = event.type;
181238

182239
switch (key) {
183240
case 'ArrowUp': {
184-
let next = this.getNextFocusedChild(target, -1);
241+
let next = getNextFocusedChild(target, -1);
185242
if (next && next.focus) next.focus();
186243
event.preventDefault();
187244

188245
return;
189246
}
190247
case 'ArrowDown':
191248
event.preventDefault();
192-
if (!this.props.show) {
193-
this.toggleOpen(event);
249+
if (!show) {
250+
toggle(event);
194251
} else {
195-
let next = this.getNextFocusedChild(target, 1);
252+
let next = getNextFocusedChild(target, 1);
196253
if (next && next.focus) next.focus();
197254
}
198255
return;
199256
case 'Escape':
200257
case 'Tab':
201-
this.props.onToggle(false, event);
258+
onToggle(false, event);
202259
break;
203260
default:
204261
}
205262
};
206263

207-
hasMenuRole() {
208-
return this.menu && matches(this.menu, '[role=menu]');
209-
}
210-
211-
focus() {
212-
const { toggleNode } = this.state.context;
213-
if (toggleNode && toggleNode.focus) {
214-
toggleNode.focus();
215-
}
216-
}
217-
218-
maybeFocusFirst() {
219-
const type = this._lastSourceEvent;
220-
let { focusFirstItemOnShow } = this.props;
221-
if (focusFirstItemOnShow == null) {
222-
focusFirstItemOnShow = this.hasMenuRole() ? 'keyboard' : false;
223-
}
224-
225-
if (
226-
focusFirstItemOnShow === false ||
227-
(focusFirstItemOnShow === 'keyboard' && !/^key.+$/.test(type))
228-
) {
229-
return;
230-
}
231-
232-
const { itemSelector } = this.props;
233-
let first = qsa(this.menu, itemSelector)[0];
234-
if (first && first.focus) first.focus();
235-
}
236-
237-
toggleOpen(event) {
238-
let show = !this.props.show;
239-
240-
this.props.onToggle(show, event);
241-
}
242-
243-
render() {
244-
const { children, ...props } = this.props;
245-
246-
delete props.onToggle;
247-
248-
if (this.menu && this.state.lastShow && !this.props.show) {
249-
this._focusInDropdown = this.menu.contains(document.activeElement);
250-
}
251-
252-
return (
253-
<DropdownContext.Provider value={this.state.context}>
254-
<Popper.Manager>
255-
{children({ props: { onKeyDown: this.handleKeyDown } })}
256-
</Popper.Manager>
257-
</DropdownContext.Provider>
258-
);
259-
}
264+
return (
265+
<DropdownContext.Provider value={context}>
266+
{children({ props: { onKeyDown: handleKeyDown } })}
267+
</DropdownContext.Provider>
268+
);
260269
}
261270

271+
Dropdown.displayName = 'ReactOverlaysDropdown';
272+
262273
Dropdown.propTypes = propTypes;
263274
Dropdown.defaultProps = defaultProps;
264275

265-
const UncontrolledDropdown = uncontrollable(Dropdown, { show: 'onToggle' });
266-
267-
UncontrolledDropdown.Menu = DropdownMenu;
268-
UncontrolledDropdown.Toggle = DropdownToggle;
276+
Dropdown.Menu = DropdownMenu;
277+
Dropdown.Toggle = DropdownToggle;
269278

270-
export default UncontrolledDropdown;
279+
export default Dropdown;

0 commit comments

Comments
 (0)