Skip to content

Commit 6b1be76

Browse files
committed
feat: Make DropdownAPI consistent and fix keyboard handling
Also removes the need for a wrapping element
1 parent a5eb1f8 commit 6b1be76

File tree

6 files changed

+172
-125
lines changed

6 files changed

+172
-125
lines changed

src/Dropdown.tsx

+35-29
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import React, { useCallback, useRef, useEffect, useMemo } from 'react';
44
import PropTypes from 'prop-types';
55
import { useUncontrolledProp } from 'uncontrollable';
66
import usePrevious from '@restart/hooks/usePrevious';
7-
import useCallbackRef from '@restart/hooks/useCallbackRef';
87
import useForceUpdate from '@restart/hooks/useForceUpdate';
8+
import useGlobalListener from '@restart/hooks/useGlobalListener';
99
import useEventCallback from '@restart/hooks/useEventCallback';
1010

1111
import DropdownContext, { DropDirection } from './DropdownContext';
@@ -90,10 +90,24 @@ export interface DropdownProps {
9090
alignEnd?: boolean;
9191
defaultShow?: boolean;
9292
show?: boolean;
93-
onToggle: (nextShow: boolean, event?: React.SyntheticEvent) => void;
93+
onToggle: (nextShow: boolean, event?: React.SyntheticEvent | Event) => void;
9494
itemSelector?: string;
9595
focusFirstItemOnShow?: false | true | 'keyboard';
96-
children: (arg: { props: DropdownInjectedProps }) => React.ReactNode;
96+
children: React.ReactNode;
97+
}
98+
99+
function useRefWithUpdate() {
100+
const forceUpdate = useForceUpdate();
101+
const ref = useRef<HTMLElement | null>(null);
102+
const attachRef = useCallback(
103+
(element: null | HTMLElement) => {
104+
ref.current = element;
105+
// ensure that a menu set triggers an update for consumers
106+
forceUpdate();
107+
},
108+
[forceUpdate],
109+
);
110+
return [ref, attachRef] as const;
97111
}
98112

99113
/**
@@ -109,39 +123,30 @@ function Dropdown({
109123
focusFirstItemOnShow,
110124
children,
111125
}: DropdownProps) {
112-
const forceUpdate = useForceUpdate();
113126
const [show, onToggle] = useUncontrolledProp(
114127
rawShow,
115128
defaultShow!,
116129
rawOnToggle,
117130
);
118131

119-
const [toggleElement, setToggle] = useCallbackRef<HTMLElement>();
120-
121132
// We use normal refs instead of useCallbackRef in order to populate the
122133
// the value as quickly as possible, otherwise the effect to focus the element
123134
// may run before the state value is set
124-
const menuRef = useRef<HTMLElement | null>(null);
135+
const [menuRef, setMenu] = useRefWithUpdate();
125136
const menuElement = menuRef.current;
126137

127-
const setMenu = useCallback(
128-
(ref: null | HTMLElement) => {
129-
menuRef.current = ref;
130-
// ensure that a menu set triggers an update for consumers
131-
forceUpdate();
132-
},
133-
[forceUpdate],
134-
);
138+
const [toggleRef, setToggle] = useRefWithUpdate();
139+
const toggleElement = toggleRef.current;
135140

136141
const lastShow = usePrevious(show);
137142
const lastSourceEvent = useRef<string | null>(null);
138143
const focusInDropdown = useRef(false);
139144

140145
const toggle = useCallback(
141-
(event) => {
142-
onToggle(!show, event);
146+
(nextShow: boolean, event?: Event | React.SyntheticEvent) => {
147+
onToggle(nextShow, event);
143148
},
144-
[onToggle, show],
149+
[onToggle],
145150
);
146151

147152
const context = useMemo(
@@ -223,20 +228,21 @@ function Dropdown({
223228
return items[index];
224229
};
225230

226-
const handleKeyDown = (event: React.KeyboardEvent) => {
231+
useGlobalListener('keydown', (event: KeyboardEvent) => {
227232
const { key } = event;
228233
const target = event.target as HTMLElement;
229234

235+
const fromMenu = menuRef.current?.contains(target);
236+
const fromToggle = toggleRef.current?.contains(target);
237+
230238
// Second only to https://github.com/twbs/bootstrap/blob/8cfbf6933b8a0146ac3fbc369f19e520bd1ebdac/js/src/dropdown.js#L400
231239
// in inscrutability
232240
const isInput = /input|textarea/i.test(target.tagName);
233-
if (
234-
isInput &&
235-
(key === ' ' ||
236-
(key !== 'Escape' &&
237-
menuRef.current &&
238-
menuRef.current.contains(target)))
239-
) {
241+
if (isInput && (key === ' ' || (key !== 'Escape' && fromMenu))) {
242+
return;
243+
}
244+
245+
if (!fromMenu && !fromToggle) {
240246
return;
241247
}
242248

@@ -253,7 +259,7 @@ function Dropdown({
253259
case 'ArrowDown':
254260
event.preventDefault();
255261
if (!show) {
256-
toggle(event);
262+
onToggle(true, event);
257263
} else {
258264
const next = getNextFocusedChild(target, 1);
259265
if (next && next.focus) next.focus();
@@ -265,11 +271,11 @@ function Dropdown({
265271
break;
266272
default:
267273
}
268-
};
274+
});
269275

270276
return (
271277
<DropdownContext.Provider value={context}>
272-
{children({ props: { onKeyDown: handleKeyDown } })}
278+
{children}
273279
</DropdownContext.Provider>
274280
);
275281
}

src/DropdownMenu.tsx

+48-46
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,45 @@
11
import PropTypes from 'prop-types';
22
import React, { useContext, useRef } from 'react';
33
import useCallbackRef from '@restart/hooks/useCallbackRef';
4-
import DropdownContext from './DropdownContext';
5-
import usePopper, { UsePopperOptions, Placement, Offset } from './usePopper';
4+
import DropdownContext, { DropdownContextValue } from './DropdownContext';
5+
import usePopper, {
6+
UsePopperOptions,
7+
Placement,
8+
Offset,
9+
UsePopperState,
10+
} from './usePopper';
611
import useRootClose, { RootCloseOptions } from './useRootClose';
712
import mergeOptionsWithPopperConfig from './mergeOptionsWithPopperConfig';
813

914
export interface UseDropdownMenuOptions {
1015
flip?: boolean;
1116
show?: boolean;
17+
fixed?: boolean;
1218
alignEnd?: boolean;
1319
usePopper?: boolean;
1420
offset?: Offset;
1521
rootCloseEvent?: RootCloseOptions['clickTrigger'];
1622
popperConfig?: Omit<UsePopperOptions, 'enabled' | 'placement'>;
1723
}
1824

19-
export interface UseDropdownMenuValue {
25+
export type UserDropdownMenuProps = Record<string, any> & {
26+
ref: React.RefCallback<HTMLElement>;
27+
style?: React.CSSProperties;
28+
'aria-labelledby'?: string;
29+
};
30+
31+
export type UserDropdownMenuArrowProps = Record<string, any> & {
32+
ref: React.RefCallback<HTMLElement>;
33+
style: React.CSSProperties;
34+
};
35+
36+
export interface UseDropdownMenuMetadata {
2037
show: boolean;
2138
alignEnd?: boolean;
2239
hasShown: boolean;
23-
close: (e: Event) => void;
24-
update: () => void;
25-
forceUpdate: () => void;
26-
props: Record<string, any> & {
27-
ref: React.RefCallback<HTMLElement>;
28-
style?: React.CSSProperties;
29-
'aria-labelledby'?: string;
30-
};
31-
arrowProps: Record<string, any> & {
32-
ref: React.RefCallback<HTMLElement>;
33-
style: React.CSSProperties;
34-
};
40+
toggle?: DropdownContextValue['toggle'];
41+
popper: UsePopperState | null;
42+
arrowProps: Partial<UserDropdownMenuArrowProps>;
3543
}
3644

3745
const noop: any = () => {};
@@ -57,11 +65,12 @@ export function useDropdownMenu(options: UseDropdownMenuOptions = {}) {
5765
flip,
5866
offset,
5967
rootCloseEvent,
68+
fixed = false,
6069
popperConfig = {},
6170
usePopper: shouldUsePopper = !!context,
6271
} = options;
6372

64-
const show = context?.show == null ? options.show : context.show;
73+
const show = context?.show == null ? !!options.show : context.show;
6574
const alignEnd =
6675
context?.alignEnd == null ? options.alignEnd : context.alignEnd;
6776

@@ -80,7 +89,7 @@ export function useDropdownMenu(options: UseDropdownMenuOptions = {}) {
8089
else if (drop === 'right') placement = alignEnd ? 'right-end' : 'right-start';
8190
else if (drop === 'left') placement = alignEnd ? 'left-end' : 'left-start';
8291

83-
const { styles, attributes, ...popper } = usePopper(
92+
const popper = usePopper(
8493
toggleElement,
8594
menuElement,
8695
mergeOptionsWithPopperConfig({
@@ -89,50 +98,40 @@ export function useDropdownMenu(options: UseDropdownMenuOptions = {}) {
8998
enableEvents: show,
9099
offset,
91100
flip,
101+
fixed,
92102
arrowElement,
93103
popperConfig,
94104
}),
95105
);
96106

97-
let menu: Partial<UseDropdownMenuValue>;
98-
99-
const menuProps = {
107+
const menuProps: UserDropdownMenuProps = {
100108
ref: setMenu || noop,
101109
'aria-labelledby': toggleElement?.id,
110+
...popper.attributes.popper,
111+
style: popper.styles.popper as any,
102112
};
103113

104-
const childArgs = {
114+
const metadata: UseDropdownMenuMetadata = {
105115
show,
106116
alignEnd,
107117
hasShown: hasShownRef.current,
108-
close: handleClose,
118+
toggle: context?.toggle,
119+
popper: shouldUsePopper ? popper : null,
120+
arrowProps: shouldUsePopper
121+
? {
122+
ref: attachArrowRef,
123+
...popper.attributes.arrow,
124+
style: popper.styles.arrow as any,
125+
}
126+
: {},
109127
};
110128

111-
if (!shouldUsePopper) {
112-
menu = { ...childArgs, props: menuProps };
113-
} else {
114-
menu = {
115-
...popper,
116-
...childArgs,
117-
props: {
118-
...menuProps,
119-
...attributes.popper,
120-
style: styles.popper as any,
121-
},
122-
arrowProps: {
123-
ref: attachArrowRef,
124-
...attributes.arrow,
125-
style: styles.arrow as any,
126-
},
127-
};
128-
}
129-
130129
useRootClose(menuElement, handleClose, {
131130
clickTrigger: rootCloseEvent,
132-
disabled: !(menu && show),
131+
disabled: !show,
133132
});
134133

135-
return menu as UseDropdownMenuValue;
134+
return [menuProps, metadata] as const;
136135
}
137136

138137
const propTypes = {
@@ -199,7 +198,10 @@ const defaultProps = {
199198
};
200199

201200
export interface DropdownMenuProps extends UseDropdownMenuOptions {
202-
children: (args: UseDropdownMenuValue) => React.ReactNode;
201+
children: (
202+
props: UserDropdownMenuProps,
203+
meta: UseDropdownMenuMetadata,
204+
) => React.ReactNode;
203205
}
204206

205207
/**
@@ -209,9 +211,9 @@ export interface DropdownMenuProps extends UseDropdownMenuOptions {
209211
* @memberOf Dropdown
210212
*/
211213
function DropdownMenu({ children, ...options }: DropdownMenuProps) {
212-
const args = useDropdownMenu(options);
214+
const [props, meta] = useDropdownMenu(options);
213215

214-
return <>{args.hasShown ? children(args) : null}</>;
216+
return <>{meta.hasShown ? children(props, meta) : null}</>;
215217
}
216218

217219
DropdownMenu.displayName = 'ReactOverlaysDropdownMenu';

src/DropdownToggle.tsx

+16-14
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import PropTypes from 'prop-types';
2-
import React, { useContext } from 'react';
2+
import React, { useContext, useCallback } from 'react';
33
import DropdownContext, { DropdownContextValue } from './DropdownContext';
44

55
export interface UseDropdownToggleProps {
66
ref: DropdownContextValue['setToggle'];
7+
onClick: React.MouseEventHandler;
78
'aria-haspopup': boolean;
89
'aria-expanded': boolean;
910
}
1011

11-
export interface UseDropdownToggleHelpers {
12+
export interface UseDropdownToggleMetadata {
1213
show: DropdownContextValue['show'];
1314
toggle: DropdownContextValue['toggle'];
1415
}
@@ -23,13 +24,21 @@ const noop = () => {};
2324
*/
2425
export function useDropdownToggle(): [
2526
UseDropdownToggleProps,
26-
UseDropdownToggleHelpers,
27+
UseDropdownToggleMetadata,
2728
] {
2829
const { show = false, toggle = noop, setToggle } =
2930
useContext(DropdownContext) || {};
31+
const handleClick = useCallback(
32+
(e) => {
33+
toggle(!show, e);
34+
},
35+
[show, toggle],
36+
);
37+
3038
return [
3139
{
3240
ref: setToggle || noop,
41+
onClick: handleClick,
3342
'aria-haspopup': true,
3443
'aria-expanded': !!show,
3544
},
@@ -58,7 +67,8 @@ const propTypes = {
5867

5968
export interface DropdownToggleProps {
6069
children: (
61-
args: UseDropdownToggleHelpers & { props: UseDropdownToggleProps },
70+
props: UseDropdownToggleProps,
71+
meta: UseDropdownToggleMetadata,
6272
) => React.ReactNode;
6373
}
6474

@@ -69,17 +79,9 @@ export interface DropdownToggleProps {
6979
* @memberOf Dropdown
7080
*/
7181
function DropdownToggle({ children }: DropdownToggleProps) {
72-
const [props, { show, toggle }] = useDropdownToggle();
82+
const [props, meta] = useDropdownToggle();
7383

74-
return (
75-
<>
76-
{children({
77-
show,
78-
toggle,
79-
props,
80-
})}
81-
</>
82-
);
84+
return <>{children(props, meta)}</>;
8385
}
8486

8587
DropdownToggle.displayName = 'ReactOverlaysDropdownToggle';

0 commit comments

Comments
 (0)