UNPKG

7.27 kBJavaScriptView Raw
1import matches from 'dom-helpers/matches';
2import qsa from 'dom-helpers/querySelectorAll';
3import React, { useCallback, useRef, useEffect, useMemo } from 'react';
4import PropTypes from 'prop-types';
5import { useUncontrolledProp } from 'uncontrollable';
6import usePrevious from '@restart/hooks/usePrevious';
7import useCallbackRef from '@restart/hooks/useCallbackRef';
8import useForceUpdate from '@restart/hooks/useForceUpdate';
9import useEventCallback from '@restart/hooks/useEventCallback';
10import DropdownContext from './DropdownContext';
11import DropdownMenu from './DropdownMenu';
12import DropdownToggle from './DropdownToggle';
13var propTypes = {
14 /**
15 * A render prop that returns the root dropdown element. The `props`
16 * argument should spread through to an element containing _both_ the
17 * menu and toggle in order to handle keyboard events for focus management.
18 *
19 * @type {Function ({
20 * props: {
21 * onKeyDown: (SyntheticEvent) => void,
22 * },
23 * }) => React.Element}
24 */
25 children: PropTypes.func.isRequired,
26
27 /**
28 * Determines the direction and location of the Menu in relation to it's Toggle.
29 */
30 drop: PropTypes.oneOf(['up', 'left', 'right', 'down']),
31
32 /**
33 * Controls the focus behavior for when the Dropdown is opened. Set to
34 * `true` to always focus the first menu item, `keyboard` to focus only when
35 * navigating via the keyboard, or `false` to disable completely
36 *
37 * The Default behavior is `false` **unless** the Menu has a `role="menu"`
38 * where it will default to `keyboard` to match the recommended [ARIA Authoring practices](https://www.w3.org/TR/wai-aria-practices-1.1/#menubutton).
39 */
40 focusFirstItemOnShow: PropTypes.oneOf([false, true, 'keyboard']),
41
42 /**
43 * A css slector string that will return __focusable__ menu items.
44 * Selectors should be relative to the menu component:
45 * e.g. ` > li:not('.disabled')`
46 */
47 itemSelector: PropTypes.string,
48
49 /**
50 * Align the menu to the 'end' side of the placement side of the Dropdown toggle. The default placement is `top-start` or `bottom-start`.
51 */
52 alignEnd: PropTypes.bool,
53
54 /**
55 * Whether or not the Dropdown is visible.
56 *
57 * @controllable onToggle
58 */
59 show: PropTypes.bool,
60
61 /**
62 * Sets the initial show position of the Dropdown.
63 */
64 defaultShow: PropTypes.bool,
65
66 /**
67 * A callback fired when the Dropdown wishes to change visibility. Called with the requested
68 * `show` value, the DOM event, and the source that fired it: `'click'`,`'keydown'`,`'rootClose'`, or `'select'`.
69 *
70 * ```ts static
71 * function(
72 * isOpen: boolean,
73 * event: SyntheticEvent,
74 * ): void
75 * ```
76 *
77 * @controllable show
78 */
79 onToggle: PropTypes.func
80};
81
82/**
83 * @displayName Dropdown
84 */
85function Dropdown(_ref) {
86 var drop = _ref.drop,
87 alignEnd = _ref.alignEnd,
88 defaultShow = _ref.defaultShow,
89 rawShow = _ref.show,
90 rawOnToggle = _ref.onToggle,
91 _ref$itemSelector = _ref.itemSelector,
92 itemSelector = _ref$itemSelector === void 0 ? '* > *' : _ref$itemSelector,
93 focusFirstItemOnShow = _ref.focusFirstItemOnShow,
94 children = _ref.children;
95 var forceUpdate = useForceUpdate();
96
97 var _useUncontrolledProp = useUncontrolledProp(rawShow, defaultShow, rawOnToggle),
98 show = _useUncontrolledProp[0],
99 onToggle = _useUncontrolledProp[1];
100
101 var _useCallbackRef = useCallbackRef(),
102 toggleElement = _useCallbackRef[0],
103 setToggle = _useCallbackRef[1]; // We use normal refs instead of useCallbackRef in order to populate the
104 // the value as quickly as possible, otherwise the effect to focus the element
105 // may run before the state value is set
106
107
108 var menuRef = useRef(null);
109 var menuElement = menuRef.current;
110 var setMenu = useCallback(function (ref) {
111 menuRef.current = ref; // ensure that a menu set triggers an update for consumers
112
113 forceUpdate();
114 }, [forceUpdate]);
115 var lastShow = usePrevious(show);
116 var lastSourceEvent = useRef(null);
117 var focusInDropdown = useRef(false);
118 var toggle = useCallback(function (event) {
119 onToggle(!show, event);
120 }, [onToggle, show]);
121 var context = useMemo(function () {
122 return {
123 toggle: toggle,
124 drop: drop,
125 show: show,
126 alignEnd: alignEnd,
127 menuElement: menuElement,
128 toggleElement: toggleElement,
129 setMenu: setMenu,
130 setToggle: setToggle
131 };
132 }, [toggle, drop, show, alignEnd, menuElement, toggleElement, setMenu, setToggle]);
133
134 if (menuElement && lastShow && !show) {
135 focusInDropdown.current = menuElement.contains(document.activeElement);
136 }
137
138 var focusToggle = useEventCallback(function () {
139 if (toggleElement && toggleElement.focus) {
140 toggleElement.focus();
141 }
142 });
143 var maybeFocusFirst = useEventCallback(function () {
144 var type = lastSourceEvent.current;
145 var focusType = focusFirstItemOnShow;
146
147 if (focusType == null) {
148 focusType = menuRef.current && matches(menuRef.current, '[role=menu]') ? 'keyboard' : false;
149 }
150
151 if (focusType === false || focusType === 'keyboard' && !/^key.+$/.test(type)) {
152 return;
153 }
154
155 var first = qsa(menuRef.current, itemSelector)[0];
156 if (first && first.focus) first.focus();
157 });
158 useEffect(function () {
159 if (show) maybeFocusFirst();else if (focusInDropdown.current) {
160 focusInDropdown.current = false;
161 focusToggle();
162 } // only `show` should be changing
163 }, [show, focusInDropdown, focusToggle, maybeFocusFirst]);
164 useEffect(function () {
165 lastSourceEvent.current = null;
166 });
167
168 var getNextFocusedChild = function getNextFocusedChild(current, offset) {
169 if (!menuRef.current) return null;
170 var items = qsa(menuRef.current, itemSelector);
171 var index = items.indexOf(current) + offset;
172 index = Math.max(0, Math.min(index, items.length));
173 return items[index];
174 };
175
176 var handleKeyDown = function handleKeyDown(event) {
177 var key = event.key;
178 var target = event.target; // Second only to https://github.com/twbs/bootstrap/blob/8cfbf6933b8a0146ac3fbc369f19e520bd1ebdac/js/src/dropdown.js#L400
179 // in inscrutability
180
181 var isInput = /input|textarea/i.test(target.tagName);
182
183 if (isInput && (key === ' ' || key !== 'Escape' && menuRef.current && menuRef.current.contains(target))) {
184 return;
185 }
186
187 lastSourceEvent.current = event.type;
188
189 switch (key) {
190 case 'ArrowUp':
191 {
192 var next = getNextFocusedChild(target, -1);
193 if (next && next.focus) next.focus();
194 event.preventDefault();
195 return;
196 }
197
198 case 'ArrowDown':
199 event.preventDefault();
200
201 if (!show) {
202 toggle(event);
203 } else {
204 var _next = getNextFocusedChild(target, 1);
205
206 if (_next && _next.focus) _next.focus();
207 }
208
209 return;
210
211 case 'Escape':
212 case 'Tab':
213 onToggle(false, event);
214 break;
215
216 default:
217 }
218 };
219
220 return /*#__PURE__*/React.createElement(DropdownContext.Provider, {
221 value: context
222 }, children({
223 props: {
224 onKeyDown: handleKeyDown
225 }
226 }));
227}
228
229Dropdown.displayName = 'ReactOverlaysDropdown';
230Dropdown.propTypes = propTypes;
231Dropdown.Menu = DropdownMenu;
232Dropdown.Toggle = DropdownToggle;
233export default Dropdown;
\No newline at end of file