From 122d15fca56089ccbb14ae8ef60082157c6f22d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maxime=20M=C3=A9riouma-Caron?= Date: Mon, 22 Feb 2021 21:24:06 -0500 Subject: [PATCH] fix: Event handling for "click outside" when mounted in shadow dom --- .../global-navigation/global-navigation.tsx | 3 +- .../nav-menu-button/nav-menu-button.tsx | 8 +- .../react/src/components/select/select.tsx | 6 +- packages/react/src/utils/event.test.ts | 84 +++++++++++++++++++ packages/react/src/utils/events.ts | 12 +++ .../stories/global-navigation.stories.tsx | 22 +++++ .../stories/nav-menu-button.stories.tsx | 12 ++- packages/storybook/stories/select.stories.tsx | 6 ++ .../stories/utils/shadow-dom-decorator.tsx | 7 ++ 9 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 packages/react/src/utils/event.test.ts create mode 100644 packages/react/src/utils/events.ts create mode 100644 packages/storybook/stories/utils/shadow-dom-decorator.tsx diff --git a/packages/react/src/components/global-navigation/global-navigation.tsx b/packages/react/src/components/global-navigation/global-navigation.tsx index e6f3a12516..11ddf1f536 100644 --- a/packages/react/src/components/global-navigation/global-navigation.tsx +++ b/packages/react/src/components/global-navigation/global-navigation.tsx @@ -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 }>` @@ -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) { diff --git a/packages/react/src/components/nav-menu-button/nav-menu-button.tsx b/packages/react/src/components/nav-menu-button/nav-menu-button.tsx index 19a6707f69..c823fcab7f 100644 --- a/packages/react/src/components/nav-menu-button/nav-menu-button.tsx +++ b/packages/react/src/components/nav-menu-button/nav-menu-button.tsx @@ -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'; @@ -67,7 +68,7 @@ interface MenuButtonProps { * Sets chevron icon * @default true * */ - hasIcon?:boolean; + hasIcon?: boolean; id?: string; options: NavMenuOption[]; } @@ -91,10 +92,7 @@ export function NavMenuButton({ const navRef = useRef(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) { diff --git a/packages/react/src/components/select/select.tsx b/packages/react/src/components/select/select.tsx index cd76f33ea5..8eff20e293 100644 --- a/packages/react/src/components/select/select.tsx +++ b/packages/react/src/components/select/select.tsx @@ -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'; @@ -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) { diff --git a/packages/react/src/utils/event.test.ts b/packages/react/src/utils/event.test.ts new file mode 100644 index 0000000000..599976cf40 --- /dev/null +++ b/packages/react/src/utils/event.test.ts @@ -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 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 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 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 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 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 as Event; + + const isInside = eventIsInside(event, container); + + expect(isInside).toBe(false); + }); +}); diff --git a/packages/react/src/utils/events.ts b/packages/react/src/utils/events.ts new file mode 100644 index 0000000000..53a05e4c9e --- /dev/null +++ b/packages/react/src/utils/events.ts @@ -0,0 +1,12 @@ +function extractNodes(eventTargets: EventTarget[]): Node[] { + return eventTargets.filter((eventTarget): eventTarget is Node => eventTarget instanceof Node); +} + +export function eventIsInside(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))) + )); +} diff --git a/packages/storybook/stories/global-navigation.stories.tsx b/packages/storybook/stories/global-navigation.stories.tsx index 0c09a3d294..f538d37f15 100644 --- a/packages/storybook/stories/global-navigation.stories.tsx +++ b/packages/storybook/stories/global-navigation.stories.tsx @@ -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', @@ -76,3 +77,24 @@ export const WithMoreIcon: Story = () => ( /> ); + +export const WithMoreIconInShadowDom: Story = () => ( +
+ +
+); +WithMoreIconInShadowDom.decorators = [ShadowDomDecorator]; diff --git a/packages/storybook/stories/nav-menu-button.stories.tsx b/packages/storybook/stories/nav-menu-button.stories.tsx index 101db73e62..299f9647e9 100644 --- a/packages/storybook/stories/nav-menu-button.stories.tsx +++ b/packages/storybook/stories/nav-menu-button.stories.tsx @@ -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', @@ -47,6 +48,15 @@ export const Desktop: Story = () => ( ); Desktop.decorators = [DesktopDecorator]; +export const DesktopInsideShadowDom: Story = () => ( + + + Menu + + +); +Desktop.decorators = [DesktopDecorator, ShadowDomDecorator]; + export const Mobile: Story = () => ( diff --git a/packages/storybook/stories/select.stories.tsx b/packages/storybook/stories/select.stories.tsx index 70f66657fc..629abd6154 100644 --- a/packages/storybook/stories/select.stories.tsx +++ b/packages/storybook/stories/select.stories.tsx @@ -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; @@ -39,6 +40,11 @@ export const Normal: Story = () => ( +); +InsideShadowDom.decorators = [ShadowDomDecorator]; + export const CustomPlaceholder: Story = () => (