Skip to content

Commit 901e319

Browse files
authored
refactor: rewrite ClickListener (#18962)
1 parent 15b90a4 commit 901e319

File tree

4 files changed

+76
-100
lines changed

4 files changed

+76
-100
lines changed

packages/react/src/components/OverflowMenu/OverflowMenu.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@ import React, {
1010
cloneElement,
1111
forwardRef,
1212
isValidElement,
13-
KeyboardEvent,
14-
MouseEvent,
1513
useCallback,
1614
useContext,
1715
useEffect,
1816
useRef,
1917
useState,
2018
type ElementType,
19+
type KeyboardEvent,
20+
type MouseEvent,
2121
type ReactElement,
2222
type ReactNode,
2323
type Ref,
@@ -26,7 +26,7 @@ import { OverflowMenuVertical } from '@carbon/icons-react';
2626
import classNames from 'classnames';
2727
import invariant from 'invariant';
2828
import PropTypes from 'prop-types';
29-
import ClickListener from '../../internal/ClickListener';
29+
import { ClickListener } from '../../internal/ClickListener';
3030
import {
3131
DIRECTION_BOTTOM,
3232
DIRECTION_TOP,
@@ -369,11 +369,12 @@ export const OverflowMenu = forwardRef<HTMLButtonElement, OverflowMenuProps>(
369369
}
370370
};
371371

372-
const handleClickOutside = (evt: MouseEvent<Document>) => {
372+
const handleClickOutside = (evt: globalThis.MouseEvent) => {
373373
if (
374374
open &&
375375
(!menuBodyRef.current ||
376-
!menuBodyRef.current.contains(evt.target as Node))
376+
(evt.target instanceof Node &&
377+
!menuBodyRef.current.contains(evt.target)))
377378
) {
378379
closeMenu();
379380
}

packages/react/src/internal/ClickListener.js

Lines changed: 0 additions & 79 deletions
This file was deleted.
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* Copyright IBM Corp. 2016, 2025
3+
*
4+
* This source code is licensed under the Apache-2.0 license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import React, {
9+
cloneElement,
10+
useEffect,
11+
useRef,
12+
type ReactElement,
13+
} from 'react';
14+
15+
interface ClickListenerProps {
16+
children: ReactElement;
17+
onClickOutside: (event: MouseEvent) => void;
18+
}
19+
20+
export const ClickListener = ({
21+
children,
22+
onClickOutside,
23+
}: ClickListenerProps) => {
24+
const elementRef = useRef<HTMLElement | null>(null);
25+
26+
const getEventTarget = (event: MouseEvent) => {
27+
if (event.composed && typeof event.composedPath === 'function') {
28+
return event.composedPath()[0];
29+
}
30+
31+
return event.target;
32+
};
33+
34+
const handleDocumentClick = (event: MouseEvent) => {
35+
if (elementRef.current?.contains) {
36+
const eventTarget = getEventTarget(event);
37+
38+
if (
39+
eventTarget instanceof Node &&
40+
!elementRef.current.contains(eventTarget)
41+
) {
42+
onClickOutside(event);
43+
}
44+
}
45+
};
46+
47+
useEffect(() => {
48+
document.addEventListener('click', handleDocumentClick);
49+
50+
return () => {
51+
document.removeEventListener('click', handleDocumentClick);
52+
};
53+
}, [onClickOutside]);
54+
55+
const handleRef = (el: HTMLElement | null) => {
56+
elementRef.current = el;
57+
58+
if ('ref' in children && typeof children.ref === 'function') {
59+
children.ref(el);
60+
}
61+
};
62+
63+
return cloneElement(children, { ref: handleRef });
64+
};

packages/react/src/internal/__tests__/ClickListener-test.js

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,19 @@
11
/**
2-
* Copyright IBM Corp. 2016, 2023
2+
* Copyright IBM Corp. 2016, 2025
33
*
44
* This source code is licensed under the Apache-2.0 license found in the
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8-
import React from 'react';
9-
import ClickListener from '../ClickListener';
10-
118
import { render } from '@testing-library/react';
9+
import React, { forwardRef } from 'react';
10+
import { ClickListener } from '../ClickListener';
1211

1312
describe('ClickListener', () => {
1413
let onClickOutside;
15-
let handleRefSpy;
1614

1715
beforeEach(() => {
1816
onClickOutside = jest.fn();
19-
handleRefSpy = jest.spyOn(ClickListener.prototype, 'handleRef');
20-
});
21-
22-
afterEach(() => {
23-
handleRefSpy.mockRestore();
2417
});
2518

2619
describe('renders as expected - Component API', () => {
@@ -56,17 +49,14 @@ describe('ClickListener', () => {
5649

5750
it('should not overwrite any children function refs', () => {
5851
const mockRef = jest.fn();
59-
class Child extends React.Component {
60-
render() {
61-
return <div />;
62-
}
63-
}
52+
const Child = forwardRef((props, ref) => <div ref={ref} {...props} />);
53+
6454
render(
6555
<ClickListener onClickOutside={onClickOutside}>
6656
<Child ref={mockRef} />
6757
</ClickListener>
6858
);
69-
expect(handleRefSpy).toHaveBeenCalledTimes(1);
59+
7060
expect(mockRef).toHaveBeenCalledTimes(1);
7161
});
7262
});

0 commit comments

Comments
 (0)