Skip to content

Commit

Permalink
update clickoutside listener
Browse files Browse the repository at this point in the history
  • Loading branch information
scurker authored and anastasialanz committed Sep 17, 2024
1 parent e05e7cb commit b6f5143
Show file tree
Hide file tree
Showing 7 changed files with 141 additions and 111 deletions.
2 changes: 1 addition & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"dependencies": {
"@popperjs/core": "^2.5.4",
"classnames": "^2.2.6",
"focus-trap-react": "8",
"focus-trap-react": "^10.2.3",
"focusable": "^2.3.0",
"keyname": "^0.1.0",
"react-id-generator": "^3.0.1",
Expand Down
75 changes: 56 additions & 19 deletions packages/react/src/components/ClickOutsideListener/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import ClickOutsideListener from './';

let wrapperNode: HTMLDivElement | null;
Expand Down Expand Up @@ -35,16 +36,20 @@ test('should render children with the text when using ClickOutsideListener', ()
expect(renderedChild).toBeInTheDocument();
});

test('should call onClickOutside when clicked outside', () => {
test('should call onClickOutside when clicked outside', async () => {
const onClickOutside = jest.fn();
const user = userEvent.setup();

render(
<ClickOutsideListener onClickOutside={onClickOutside}>
<div>bar</div>
</ClickOutsideListener>
</ClickOutsideListener>,
{
container: mountNode as HTMLElement
}
);

fireEvent.click(screen.getByRole('link', { name: 'Click Me!' }));
await user.click(screen.getByRole('link', { name: 'Click Me!' }));
expect(onClickOutside).toBeCalled();
});

Expand All @@ -54,10 +59,13 @@ test('should call onClickOutside with event', () => {
render(
<ClickOutsideListener onClickOutside={onClickOutside}>
<div>bar</div>
</ClickOutsideListener>
</ClickOutsideListener>,
{
container: mountNode as HTMLElement
}
);

const event = new MouseEvent('click', { bubbles: true });
const event = new MouseEvent('mouseup', { bubbles: true });
fireEvent(screen.getByTestId('link'), event);
expect(onClickOutside).toHaveBeenCalledWith(event);
});
Expand All @@ -68,24 +76,31 @@ test('should call onClickOutside when touched outside', () => {
render(
<ClickOutsideListener onClickOutside={onClickOutside}>
<div>bar</div>
</ClickOutsideListener>
</ClickOutsideListener>,
{
container: mountNode as HTMLElement
}
);

const event = new TouchEvent('touchend', { bubbles: true });
fireEvent(screen.getByTestId('link'), event);
expect(onClickOutside).toHaveBeenCalledTimes(1);
});

test('should not call onClickOutside when clicked inside', () => {
test('should not call onClickOutside when clicked inside', async () => {
const onClickOutside = jest.fn();
const user = userEvent.setup();

render(
<ClickOutsideListener onClickOutside={onClickOutside}>
<div>Click me!</div>
</ClickOutsideListener>
</ClickOutsideListener>,
{
container: mountNode as HTMLElement
}
);

fireEvent.click(screen.getByText('Click me!'));
await user.click(screen.getByText('Click me!'));
expect(onClickOutside).not.toBeCalled();
});

Expand All @@ -95,7 +110,10 @@ test('should not call onClickOutside when touched inside', () => {
render(
<ClickOutsideListener onClickOutside={onClickOutside}>
<div data-testid="test">Touch me!</div>
</ClickOutsideListener>
</ClickOutsideListener>,
{
container: mountNode as HTMLElement
}
);

const event = new TouchEvent('touchend');
Expand All @@ -112,7 +130,10 @@ test('should allow mouseEvent to be changed', () => {
mouseEvent="mousedown"
>
<div>bar</div>
</ClickOutsideListener>
</ClickOutsideListener>,
{
container: mountNode as HTMLElement
}
);

const event = new MouseEvent('mousedown', { bubbles: true });
Expand All @@ -126,7 +147,10 @@ test('should allow mouseEvent to be false', () => {
render(
<ClickOutsideListener onClickOutside={onClickOutside} mouseEvent={false}>
<div>bar</div>
</ClickOutsideListener>
</ClickOutsideListener>,
{
container: mountNode as HTMLElement
}
);

const event = new MouseEvent('click', { bubbles: true });
Expand All @@ -143,7 +167,10 @@ test('should allow touchEvent to be changed', () => {
touchEvent="touchstart"
>
<div>div</div>
</ClickOutsideListener>
</ClickOutsideListener>,
{
container: mountNode as HTMLElement
}
);

const event = new TouchEvent('touchstart', { bubbles: true });
Expand All @@ -157,24 +184,31 @@ test('should allow touchEvent to be false', () => {
render(
<ClickOutsideListener onClickOutside={onClickOutside} touchEvent={false}>
<div>div</div>
</ClickOutsideListener>
</ClickOutsideListener>,
{
container: mountNode as HTMLElement
}
);

const event = new TouchEvent('touchend', { bubbles: true });
fireEvent(screen.getByTestId('link'), event);
expect(onClickOutside).not.toBeCalled();
});

test('should remove event listeners when props change', () => {
test('should remove event listeners when props change', async () => {
const onClickOutside = jest.fn();
const user = userEvent.setup();

const { rerender } = render(
<ClickOutsideListener onClickOutside={onClickOutside}>
<div>bar</div>
</ClickOutsideListener>
</ClickOutsideListener>,
{
container: mountNode as HTMLElement
}
);

fireEvent.click(screen.getByTestId('link'));
await user.click(screen.getByTestId('link'));
expect(onClickOutside).toHaveBeenCalledTimes(1);

rerender(
Expand All @@ -183,7 +217,7 @@ test('should remove event listeners when props change', () => {
</ClickOutsideListener>
);

fireEvent.click(screen.getByTestId('link'));
await user.click(screen.getByTestId('link'));
expect(onClickOutside).toHaveBeenCalledTimes(1);
});

Expand All @@ -194,7 +228,10 @@ test('should not remove event listeners when event props do not change', () => {
const { getByTestId } = render(
<ClickOutsideListener onClickOutside={onClickOutside} mouseEvent="click">
<div>bar</div>
</ClickOutsideListener>
</ClickOutsideListener>,
{
container: mountNode as HTMLElement
}
);

fireEvent.click(getByTestId('link'));
Expand Down
109 changes: 42 additions & 67 deletions packages/react/src/components/ClickOutsideListener/index.tsx
Original file line number Diff line number Diff line change
@@ -1,97 +1,72 @@
import React from 'react';
import React, { useRef, useEffect } from 'react';
import setRef from '../../utils/setRef';

export interface ClickOutsideListenerProps<
T extends HTMLElement = HTMLElement
> {
children?: React.ReactNode;
children?: React.ReactElement;
onClickOutside: (e: MouseEvent | TouchEvent) => void;
mouseEvent?: 'mousedown' | 'click' | 'mouseup' | false;
touchEvent?: 'touchstart' | 'touchend' | false;
target?: T;
}

export default class ClickOutsideListener extends React.Component<ClickOutsideListenerProps> {
static displayName = 'ClickOutsideListener';

static defaultProps = {
mouseEvent: 'click',
touchEvent: 'touchend'
};

private nodeRef: HTMLElement | null;

handleEvent = (event: MouseEvent | TouchEvent) => {
const { nodeRef, props } = this;
const { onClickOutside, target } = props;
function ClickOutsideListener(
{
children,
mouseEvent = 'mouseup',
touchEvent = 'touchend',
target,
onClickOutside = () => null
}: ClickOutsideListenerProps,
ref: React.ForwardedRef<HTMLElement>
): JSX.Element | null {
const childElementRef = useRef<HTMLElement>();

const handleEvent = (event: MouseEvent | TouchEvent) => {
if (event.defaultPrevented) {
return;
}

const eventTarget = event.target as HTMLElement;
if (
(target && !target.contains(eventTarget)) ||
(nodeRef && !nodeRef.contains(eventTarget))
(!!target && !target.contains(eventTarget)) ||
(childElementRef.current &&
!childElementRef.current.contains(eventTarget))
) {
onClickOutside(event);
}
};

componentDidMount() {
this.attachEventListeners();
}

componentDidUpdate(prevProps: ClickOutsideListenerProps) {
const { mouseEvent, touchEvent } = this.props;
if (
prevProps.mouseEvent !== mouseEvent ||
prevProps.touchEvent !== touchEvent
) {
this.removeEventListeners(prevProps.mouseEvent, prevProps.touchEvent);
this.attachEventListeners();
}
}

componentWillUnmount() {
const { mouseEvent, touchEvent } = this.props;
this.removeEventListeners(mouseEvent, touchEvent);
}

private attachEventListeners = () => {
const { mouseEvent, touchEvent } = this.props;
typeof mouseEvent === 'string' &&
document.addEventListener(mouseEvent, this.handleEvent);
typeof touchEvent === 'string' &&
document.addEventListener(touchEvent, this.handleEvent);
const resolveRef = (node: HTMLElement) => {
childElementRef.current = node;
// Ref for this component should pass-through to the child node
setRef(ref, node);
// If child has its own ref, we want to update
// its ref with the newly cloned node
const { ref: childRef } = children as any;
setRef(childRef, node);
};

private removeEventListeners = (
mouseEvent: ClickOutsideListenerProps['mouseEvent'],
touchEvent: ClickOutsideListenerProps['touchEvent']
) => {
useEffect(() => {
typeof mouseEvent === 'string' &&
document.removeEventListener(mouseEvent, this.handleEvent);
document.addEventListener(mouseEvent, handleEvent);
typeof touchEvent === 'string' &&
document.removeEventListener(touchEvent, this.handleEvent);
};
document.addEventListener(touchEvent, handleEvent);

resolveRef = (node: HTMLElement) => {
this.nodeRef = node;
return () => {
typeof mouseEvent === 'string' &&
document.removeEventListener(mouseEvent, handleEvent);
typeof touchEvent === 'string' &&
document.removeEventListener(touchEvent, handleEvent);
};
}, [mouseEvent, touchEvent]);

setRef;
// If child has its own ref, we want to update
// its ref with the newly cloned node
const { ref } = this.props.children as any;
setRef(ref, node);
};

render() {
const { props, resolveRef } = this;
return !props.children
? null
: React.cloneElement(props.children as any, {
ref: resolveRef
});
}
return !children
? null
: React.cloneElement(children as React.ReactElement, { ref: resolveRef });
}

ClickOutsideListener.displayName = 'ClickOutsideListener';

export default React.forwardRef(ClickOutsideListener);
18 changes: 12 additions & 6 deletions packages/react/src/components/OptionsMenu/OptionsMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import OptionsMenu from './';
import axe from '../../axe';

Expand Down Expand Up @@ -117,7 +118,9 @@ test('should click trigger with down key on trigger', () => {
expect(screen.getByRole('button')).toHaveAttribute('aria-expanded', 'true');
});

test('should focus trigger on close', () => {
test('should focus trigger on close', async () => {
const user = userEvent.setup();

render(
<OptionsMenu trigger={trigger}>
<li className="foo">option 1</li>
Expand All @@ -126,8 +129,8 @@ test('should focus trigger on close', () => {

const button = screen.getByRole('button');

fireEvent.click(button);
fireEvent.click(button); // to close
await user.click(button); // opens menu
await user.click(button); // closes menu
expect(button).toHaveFocus();
});

Expand All @@ -147,7 +150,9 @@ test('should call onClose when closed', () => {
expect(onClose).toBeCalled();
});

test('should close menu when click outside event occurs', () => {
test('should close menu when click outside event occurs', async () => {
const user = userEvent.setup();

render(
<>
<button>Click me!</button>
Expand All @@ -156,9 +161,10 @@ test('should close menu when click outside event occurs', () => {
</OptionsMenu>
</>
);
fireEvent.click(screen.getByRole('button', { name: 'trigger' }));

await user.click(screen.getByRole('button', { name: 'trigger' }));
expect(screen.getByRole('menu')).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getByRole('button', { name: 'Click me!' }));
await user.click(screen.getByRole('button', { name: 'Click me!' }));
expect(screen.getByRole('menu')).toHaveAttribute('aria-expanded', 'false');
});

Expand Down
Loading

0 comments on commit b6f5143

Please sign in to comment.