Skip to content

Commit

Permalink
fix: Event handling for "click outside" when mounted in shadow dom
Browse files Browse the repository at this point in the history
  • Loading branch information
meriouma authored and maxime-gendron committed Feb 23, 2021
1 parent 0063cdb commit 122d15f
Show file tree
Hide file tree
Showing 9 changed files with 149 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { ReactElement, useEffect, useRef, useState } from 'react';
import { NavLink, NavLinkProps } from 'react-router-dom';
import styled from 'styled-components';
import { focus } from '../../utils/css-state';
import { eventIsInside } from '../../utils/events';
import { Icon, IconName } from '../icon/icon';

const Wrapper = styled.div<{ padding: number }>`
Expand Down Expand Up @@ -154,7 +155,7 @@ export function GlobalNavigation({

function handleClickOutside(event: MouseEvent): void {
const wrapperRefIsNull = wrapperRef.current === null;
const wrapperContainsClick = wrapperRef.current?.contains(event.target as Node);
const wrapperContainsClick = eventIsInside(event, wrapperRef.current);
const shouldClose = wrapperRefIsNull || !wrapperContainsClick;

if (shouldClose) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import React, {
import styled from 'styled-components';
import { useTranslation } from '../../i18n/use-translation';
import { Theme } from '../../themes';
import { eventIsInside } from '../../utils/events';
import { v4 as uuid } from '../../utils/uuid';
import { AbstractButton } from '../buttons/abstract-button';
import { useDeviceContext } from '../device-context-provider/device-context-provider';
Expand Down Expand Up @@ -67,7 +68,7 @@ interface MenuButtonProps {
* Sets chevron icon
* @default true
* */
hasIcon?:boolean;
hasIcon?: boolean;
id?: string;
options: NavMenuOption[];
}
Expand All @@ -91,10 +92,7 @@ export function NavMenuButton({
const navRef = useRef<HTMLDivElement>(null);

const handleClickOutside: (event: MouseEvent) => void = useCallback((event) => {
const clickIsOutside = (
!buttonRef.current?.contains(event.target as Node)
&& !navMenuRef.current?.contains(event.target as Node)
);
const clickIsOutside = !eventIsInside(event, buttonRef.current, navMenuRef.current);
const shouldClose = (navMenuRef.current === null || clickIsOutside) && isOpen;

if (shouldClose) {
Expand Down
6 changes: 2 additions & 4 deletions packages/react/src/components/select/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import React, {
import styled from 'styled-components';
import { useTranslation } from '../../i18n/use-translation';
import { Theme } from '../../themes';
import { eventIsInside } from '../../utils/events';
import { v4 as uuid } from '../../utils/uuid';
import { ChooseInput } from '../choose-input/choose-input';
import { useDeviceContext } from '../device-context-provider/device-context-provider';
Expand Down Expand Up @@ -268,10 +269,7 @@ export function Select({
}, [disabled, open, searchable, selectedOptionValue]);

const handleClickOutside: (event: MouseEvent) => void = useCallback((event) => {
const clickIsOutside = (
!wrapperRef.current?.contains(event.target as Node)
&& !listboxRef.current?.contains(event.target as Node)
);
const clickIsOutside = !eventIsInside(event, wrapperRef.current, listboxRef.current);
const shouldClose = (wrapperRef.current === null || clickIsOutside) && open;

if (shouldClose) {
Expand Down
84 changes: 84 additions & 0 deletions packages/react/src/utils/event.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { eventIsInside } from './events';

describe('eventIsInside', () => {
it('should return true when event target\'s is inside the container', () => {
const container = document.createElement('div');
const target = document.createElement('a');
container.appendChild(target);
const event = {
target,
} as Partial<Event> as Event;

const isInside = eventIsInside(event, container);

expect(isInside).toBe(true);
});

it('should return true when event target\'s is the container', () => {
const container = document.createElement('div');
const event = {
target: container,
} as Partial<Event> as Event;

const isInside = eventIsInside(event, container);

expect(isInside).toBe(true);
});

it('should return false when event target\'s is outside the container', () => {
const container = document.createElement('div');
const target = document.createElement('a');
const event = {
target,
} as Partial<Event> as Event;

const isInside = eventIsInside(event, container);

expect(isInside).toBe(false);
});

it('should return true when event target\'s from composedPath is inside the container', () => {
const container = document.createElement('div');
const target = document.createElement('a');
container.appendChild(target);
const event = {
composed: true,
composedPath(): EventTarget[] {
return [target, container, document];
},
} as Partial<Event> as Event;

const isInside = eventIsInside(event, container);

expect(isInside).toBe(true);
});

it('should return true when event target\'s from composedPath is the container', () => {
const container = document.createElement('div');
const event = {
composed: true,
composedPath(): EventTarget[] {
return [container, document];
},
} as Partial<Event> as Event;

const isInside = eventIsInside(event, container);

expect(isInside).toBe(true);
});

it('should return false when event target\'s from composedPath is outside the container', () => {
const container = document.createElement('div');
const target = document.createElement('a');
const event = {
composed: true,
composedPath(): EventTarget[] {
return [target, document];
},
} as Partial<Event> as Event;

const isInside = eventIsInside(event, container);

expect(isInside).toBe(false);
});
});
12 changes: 12 additions & 0 deletions packages/react/src/utils/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
function extractNodes(eventTargets: EventTarget[]): Node[] {
return eventTargets.filter((eventTarget): eventTarget is Node => eventTarget instanceof Node);
}

export function eventIsInside<T extends Event>(event: T, ...containers: (HTMLElement | null)[]): boolean {
const composedNodes: Node[] = event.composed ? extractNodes(event.composedPath()) : [];

return containers.filter(Boolean).some((container) => (
container?.contains(event.target as Node)
|| (event.composed && composedNodes.some((composedNode) => container?.contains(composedNode)))
));
}
22 changes: 22 additions & 0 deletions packages/storybook/stories/global-navigation.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { GlobalNavigation, GlobalNavigationItem } from '@equisoft/design-element
import { Story } from '@storybook/react';
import React from 'react';
import { RouterDecorator } from './utils/router-decorator';
import { ShadowDomDecorator } from './utils/shadow-dom-decorator';

export default {
title: 'Global Navigation',
Expand Down Expand Up @@ -76,3 +77,24 @@ export const WithMoreIcon: Story = () => (
/>
</div>
);

export const WithMoreIconInShadowDom: Story = () => (
<div style={{ height: '350px' }}>
<GlobalNavigation
mainItems={items}
footerItems={[
{
iconName: 'info',
name: 'story 8',
href: '/story8',
},
{
iconName: 'helpCircle',
name: 'story 9',
href: '/story9',
},
]}
/>
</div>
);
WithMoreIconInShadowDom.decorators = [ShadowDomDecorator];
12 changes: 11 additions & 1 deletion packages/storybook/stories/nav-menu-button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { ApplicationMenu, NavMenuButton } from '@equisoft/design-elements-react'
import { Story } from '@storybook/react';
import React from 'react';
import styled from 'styled-components';
import { RouterDecorator } from './utils/router-decorator';
import { DesktopDecorator, MobileDecorator } from './utils/device-context-decorator';
import { RouterDecorator } from './utils/router-decorator';
import { ShadowDomDecorator } from './utils/shadow-dom-decorator';

export default {
title: 'Nav Menu Button',
Expand Down Expand Up @@ -47,6 +48,15 @@ export const Desktop: Story = () => (
);
Desktop.decorators = [DesktopDecorator];

export const DesktopInsideShadowDom: Story = () => (
<StyledDiv>
<ApplicationMenu>
<NavMenuButton options={options}>Menu</NavMenuButton>
</ApplicationMenu>
</StyledDiv>
);
Desktop.decorators = [DesktopDecorator, ShadowDomDecorator];

export const Mobile: Story = () => (
<StyledDiv>
<ApplicationMenu>
Expand Down
6 changes: 6 additions & 0 deletions packages/storybook/stories/select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Story } from '@storybook/react';
import React from 'react';
import styled from 'styled-components';
import { decorateWith } from './utils/decorator';
import { ShadowDomDecorator } from './utils/shadow-dom-decorator';

const Container = styled.div`
height: 240px;
Expand Down Expand Up @@ -39,6 +40,11 @@ export const Normal: Story = () => (
<Select label="Select an option" hint="Hint" options={provinces} />
);

export const InsideShadowDom: Story = () => (
<Select label="Select an option" hint="Hint" options={provinces} />
);
InsideShadowDom.decorators = [ShadowDomDecorator];

export const CustomPlaceholder: Story = () => (
<Select label="Select an option" options={provinces} placeholder="Custom placeholder" />
);
Expand Down
7 changes: 7 additions & 0 deletions packages/storybook/stories/utils/shadow-dom-decorator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ThemeWrapper } from '@equisoft/design-elements-react';
import React, { ComponentType } from 'react';
import { Decorator } from './decorator';

export const ShadowDomDecorator: Decorator = (Story: ComponentType) => (
<ThemeWrapper isolateStyles><Story /></ThemeWrapper>
);

0 comments on commit 122d15f

Please sign in to comment.