Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 3ed2d85

Browse files
authored
feat!: Make DropdownAPI consistent and fix keyboard handling (#843)
BREAKING CHANGE: Dropdown does not inject props or accept a children render function (it just works)
1 parent 4a2bc0c commit 3ed2d85

File tree

7 files changed

+275
-228
lines changed

7 files changed

+275
-228
lines changed

‎src/Dropdown.tsx

Lines changed: 41 additions & 30 deletions
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';
@@ -24,7 +24,7 @@ const propTypes = {
2424
* },
2525
* }) => React.Element}
2626
*/
27-
children: PropTypes.func.isRequired,
27+
children: PropTypes.node,
2828

2929
/**
3030
* Determines the direction and location of the Menu in relation to it's Toggle.
@@ -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-
consthandleKeyDown=(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,23 +259,28 @@ 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();
260266
}
261267
return;
262268
case 'Escape':
263269
case 'Tab':
270+
if (key === 'Escape') {
271+
event.preventDefault();
272+
event.stopPropagation();
273+
}
274+
264275
onToggle(false, event);
265276
break;
266277
default:
267278
}
268-
};
279+
});
269280

270281
return (
271282
<DropdownContext.Provider value={context}>
272-
{children({props: {onKeyDown: handleKeyDown}})}
283+
{children}
273284
</DropdownContext.Provider>
274285
);
275286
}

‎src/DropdownMenu.tsx

Lines changed: 48 additions & 46 deletions
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 menuas 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

Lines changed: 16 additions & 14 deletions
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)

AltStyle によって変換されたページ (->オリジナル) /