From 9805148fe34a3abc31189abb53a4e52280e4cca0 Mon Sep 17 00:00:00 2001 From: Greg Thompson Date: Thu, 5 May 2022 12:36:10 -0500 Subject: [PATCH 1/9] euiOverlayMask accept ref; mock updates --- .../collapsible_nav/collapsible_nav.test.tsx | 8 +- src/components/flyout/flyout.test.tsx | 8 +- src/components/overlay_mask/overlay_mask.tsx | 162 +++++++++--------- 3 files changed, 92 insertions(+), 86 deletions(-) diff --git a/src/components/collapsible_nav/collapsible_nav.test.tsx b/src/components/collapsible_nav/collapsible_nav.test.tsx index 650930c587d..9555ddb1b21 100644 --- a/src/components/collapsible_nav/collapsible_nav.test.tsx +++ b/src/components/collapsible_nav/collapsible_nav.test.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { forwardRef as mockForwardRef } from 'react'; import { render, mount } from 'enzyme'; import { requiredProps, takeMountedSnapshot } from '../../test'; @@ -14,8 +14,10 @@ import { EuiCollapsibleNav } from './collapsible_nav'; import { EuiOverlayMaskProps } from '../overlay_mask'; jest.mock('../overlay_mask', () => ({ - EuiOverlayMask: ({ headerZindexLocation, ...props }: any) => ( -
+ EuiOverlayMask: mockForwardRef( + ({ headerZindexLocation, ...props }: any, ref) => ( +
+ ) ), })); diff --git a/src/components/flyout/flyout.test.tsx b/src/components/flyout/flyout.test.tsx index 79bfa265fc4..a1dc9573e9f 100644 --- a/src/components/flyout/flyout.test.tsx +++ b/src/components/flyout/flyout.test.tsx @@ -6,15 +6,17 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { forwardRef as mockForwardRef } from 'react'; import { render, mount } from 'enzyme'; import { requiredProps, takeMountedSnapshot } from '../../test'; import { EuiFlyout, SIZES, PADDING_SIZES, SIDES } from './flyout'; jest.mock('../overlay_mask', () => ({ - EuiOverlayMask: ({ headerZindexLocation, ...props }: any) => ( -
+ EuiOverlayMask: mockForwardRef( + ({ headerZindexLocation, ...props }: any, ref) => ( +
+ ) ), })); diff --git a/src/components/overlay_mask/overlay_mask.tsx b/src/components/overlay_mask/overlay_mask.tsx index 114fa01dc8a..0d2d6cfbe7e 100644 --- a/src/components/overlay_mask/overlay_mask.tsx +++ b/src/components/overlay_mask/overlay_mask.tsx @@ -12,9 +12,9 @@ */ import React, { - FunctionComponent, HTMLAttributes, ReactNode, + forwardRef, useEffect, useRef, useState, @@ -22,6 +22,7 @@ import React, { import { createPortal } from 'react-dom'; import classNames from 'classnames'; import { CommonProps, keysOf } from '../common'; +import { useCombinedRefs } from '../../services'; export interface EuiOverlayMaskInterface { /** @@ -45,86 +46,87 @@ export type EuiOverlayMaskProps = CommonProps & > & EuiOverlayMaskInterface; -export const EuiOverlayMask: FunctionComponent = ({ - className, - children, - onClick, - headerZindexLocation = 'above', - ...rest -}) => { - const overlayMaskNode = useRef(); - const [isPortalTargetReady, setIsPortalTargetReady] = useState(false); - - useEffect(() => { - document.body.classList.add('euiBody-hasOverlayMask'); - - return () => { - document.body.classList.remove('euiBody-hasOverlayMask'); - }; - }, []); - - useEffect(() => { - if (typeof document !== 'undefined') { - overlayMaskNode.current = document.createElement('div'); - } - }, []); - - useEffect(() => { - const portalTarget = overlayMaskNode.current; - - if (portalTarget) { - document.body.appendChild(portalTarget); - } +export const EuiOverlayMask = forwardRef( + ( + { className, children, onClick, headerZindexLocation = 'above', ...rest }, + ref + ) => { + const overlayMaskNode = useRef(); + const combinedMaskRef = useCombinedRefs([overlayMaskNode, ref]); + const [isPortalTargetReady, setIsPortalTargetReady] = useState(false); + + useEffect(() => { + document.body.classList.add('euiBody-hasOverlayMask'); + + return () => { + document.body.classList.remove('euiBody-hasOverlayMask'); + }; + }, []); + + useEffect(() => { + if (typeof document !== 'undefined') { + combinedMaskRef(document.createElement('div')); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps - setIsPortalTargetReady(true); + useEffect(() => { + const portalTarget = overlayMaskNode.current; - return () => { if (portalTarget) { - document.body.removeChild(portalTarget); - } - }; - }, []); - - useEffect(() => { - if (!overlayMaskNode.current) return; - keysOf(rest).forEach((key) => { - if (typeof rest[key] !== 'string') { - throw new Error( - `Unhandled property type. EuiOverlayMask property ${key} is not a string.` - ); - } - if (overlayMaskNode.current) { - overlayMaskNode.current.setAttribute(key, rest[key]!); + document.body.appendChild(portalTarget); } - }); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - useEffect(() => { - if (!overlayMaskNode.current) return; - overlayMaskNode.current.className = classNames( - 'euiOverlayMask', - `euiOverlayMask--${headerZindexLocation}Header`, - className - ); - }, [className, headerZindexLocation]); - - useEffect(() => { - const portalTarget = overlayMaskNode.current; - if (!portalTarget || !onClick) return; - - const listener = (e: Event) => { - if (e.target === portalTarget) { - onClick(); - } - }; - portalTarget.addEventListener('click', listener); - - return () => { - portalTarget.removeEventListener('click', listener); - }; - }, [onClick]); - - return isPortalTargetReady ? ( - <>{createPortal(children, overlayMaskNode.current!)} - ) : null; -}; + + setIsPortalTargetReady(true); + + return () => { + if (portalTarget) { + document.body.removeChild(portalTarget); + } + }; + }, []); + + useEffect(() => { + if (!overlayMaskNode.current) return; + keysOf(rest).forEach((key) => { + if (typeof rest[key] !== 'string') { + throw new Error( + `Unhandled property type. EuiOverlayMask property ${key} is not a string.` + ); + } + if (overlayMaskNode.current) { + overlayMaskNode.current.setAttribute(key, rest[key]!); + } + }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!overlayMaskNode.current) return; + overlayMaskNode.current.className = classNames( + 'euiOverlayMask', + `euiOverlayMask--${headerZindexLocation}Header`, + className + ); + }, [className, headerZindexLocation]); + + useEffect(() => { + const portalTarget = overlayMaskNode.current; + if (!portalTarget || !onClick) return; + + const listener = (e: Event) => { + if (e.target === portalTarget) { + onClick(); + } + }; + portalTarget.addEventListener('click', listener); + + return () => { + portalTarget.removeEventListener('click', listener); + }; + }, [onClick]); + + return isPortalTargetReady ? ( + <>{createPortal(children, overlayMaskNode.current!)} + ) : null; + } +); +EuiOverlayMask.displayName = 'EuiOverlayMask'; From 559d42cb9d4b55e60cfb190e76f08c471e6235da Mon Sep 17 00:00:00 2001 From: Greg Thompson Date: Thu, 5 May 2022 12:37:16 -0500 Subject: [PATCH 2/9] euiFocusTrap pass event to callback --- src/components/focus_trap/focus_trap.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/focus_trap/focus_trap.tsx b/src/components/focus_trap/focus_trap.tsx index b210e9f902c..d3160bbb1d7 100644 --- a/src/components/focus_trap/focus_trap.tsx +++ b/src/components/focus_trap/focus_trap.tsx @@ -81,7 +81,7 @@ export class EuiFocusTrap extends Component { node.setAttribute('data-autofocus', 'true'); }; - onMouseupOutside = (e: MouseEvent | TouchEvent) => { + onMouseupOutside = (e: MouseEvent | TouchEvent) => () => { this.removeMouseupListener(); // Timeout gives precedence to the consumer to initiate close if it has toggle behavior. // Otherwise this event may occur first and the consumer toggle will reopen the flyout. @@ -98,14 +98,14 @@ export class EuiFocusTrap extends Component { document.removeEventListener('touchend', this.onMouseupOutside); }; - handleOutsideClick: ReactFocusOnProps['onClickOutside'] = (...args) => { + handleOutsideClick: ReactFocusOnProps['onClickOutside'] = (e) => { const { onClickOutside, clickOutsideDisables, closeOnMouseup } = this.props; if (clickOutsideDisables) { this.setState({ hasBeenDisabledByClick: true }); } if (onClickOutside) { - closeOnMouseup ? this.addMouseupListener() : onClickOutside(...args); + closeOnMouseup ? this.addMouseupListener() : onClickOutside(e); } }; From 2e440ffa9b3ac0b7291365ba57647043cc81bb15 Mon Sep 17 00:00:00 2001 From: Greg Thompson Date: Thu, 5 May 2022 12:39:21 -0500 Subject: [PATCH 3/9] euiFlyout take advantage of ref and event data --- src/components/flyout/flyout.tsx | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/components/flyout/flyout.tsx b/src/components/flyout/flyout.tsx index f79f5b5a241..ce702174ea2 100644 --- a/src/components/flyout/flyout.tsx +++ b/src/components/flyout/flyout.tsx @@ -8,6 +8,7 @@ import React, { useEffect, + useRef, useState, forwardRef, ComponentPropsWithRef, @@ -15,7 +16,7 @@ import React, { ElementType, Fragment, FunctionComponent, - MouseEvent, + MouseEvent as ReactMouseEvent, MutableRefObject, } from 'react'; import classnames from 'classnames'; @@ -81,7 +82,7 @@ export const PADDING_SIZES = keysOf(paddingSizeToClassNameMap); type _EuiFlyoutPaddingSize = typeof PADDING_SIZES[number]; interface _EuiFlyoutProps { - onClose: () => void; + onClose: (event: MouseEvent | TouchEvent | KeyboardEvent) => void; /** * Defines the width of the panel. * Pass a predefined size of `s | m | l`, or pass any number/string compatible with the CSS `width` attribute @@ -204,6 +205,7 @@ export const EuiFlyout = forwardRef( | null ) => { const Element = as || defaultElement; + const maskRef = useRef(null); /** * Setting the initial state of pushed based on the `type` prop * and if the current window size is large enough (larger than `pushMinBreakpoint`) @@ -282,7 +284,7 @@ export const EuiFlyout = forwardRef( const onKeyDown = (event: KeyboardEvent) => { if (!isPushed && event.key === keys.ESCAPE) { event.preventDefault(); - onClose(); + onClose(event); } }; @@ -336,9 +338,9 @@ export const EuiFlyout = forwardRef( data-test-subj="euiFlyoutCloseButton" {...closeButtonProps} className={closeButtonClasses} - onClick={(e: MouseEvent) => { - onClose(); - closeButtonProps?.onClick && closeButtonProps.onClick(e); + onClick={(e: ReactMouseEvent) => { + onClose(e.nativeEvent); + closeButtonProps?.onClick?.(e); }} /> )} @@ -347,10 +349,12 @@ export const EuiFlyout = forwardRef( } const isDefaultConfiguration = ownFocus && !isPushed; - const onClickOutside = - (isDefaultConfiguration && outsideClickCloses !== false) || + const onClickOutside = (event: MouseEvent | TouchEvent) => + (isDefaultConfiguration && + outsideClickCloses !== false && + event.target === maskRef.current) || outsideClickCloses === true - ? onClose + ? onClose(event) : undefined; /* * Trap focus even when `ownFocus={false}`, otherwise closing @@ -388,7 +392,11 @@ export const EuiFlyout = forwardRef( // If ownFocus is set, wrap with an overlay and allow the user to click it to close it. if (isDefaultConfiguration) { flyout = ( - + {flyout} ); From ca8f0fe8776b8f6df5e6c67cddb8c1527732877a Mon Sep 17 00:00:00 2001 From: Greg Thompson Date: Thu, 5 May 2022 14:10:45 -0500 Subject: [PATCH 4/9] CL --- src/components/focus_trap/focus_trap.tsx | 6 +++--- upcoming_changelogs/5876.md | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 upcoming_changelogs/5876.md diff --git a/src/components/focus_trap/focus_trap.tsx b/src/components/focus_trap/focus_trap.tsx index d3160bbb1d7..b6403ff39de 100644 --- a/src/components/focus_trap/focus_trap.tsx +++ b/src/components/focus_trap/focus_trap.tsx @@ -81,7 +81,7 @@ export class EuiFocusTrap extends Component { node.setAttribute('data-autofocus', 'true'); }; - onMouseupOutside = (e: MouseEvent | TouchEvent) => () => { + onMouseupOutside = (e: MouseEvent | TouchEvent) => { this.removeMouseupListener(); // Timeout gives precedence to the consumer to initiate close if it has toggle behavior. // Otherwise this event may occur first and the consumer toggle will reopen the flyout. @@ -98,14 +98,14 @@ export class EuiFocusTrap extends Component { document.removeEventListener('touchend', this.onMouseupOutside); }; - handleOutsideClick: ReactFocusOnProps['onClickOutside'] = (e) => { + handleOutsideClick: ReactFocusOnProps['onClickOutside'] = (event) => { const { onClickOutside, clickOutsideDisables, closeOnMouseup } = this.props; if (clickOutsideDisables) { this.setState({ hasBeenDisabledByClick: true }); } if (onClickOutside) { - closeOnMouseup ? this.addMouseupListener() : onClickOutside(e); + closeOnMouseup ? this.addMouseupListener() : onClickOutside(event); } }; diff --git a/upcoming_changelogs/5876.md b/upcoming_changelogs/5876.md new file mode 100644 index 00000000000..716db53c327 --- /dev/null +++ b/upcoming_changelogs/5876.md @@ -0,0 +1,6 @@ +- Updated `EuiOverlayMask` to accept a React ref + +**Bug fixes** + +- Fixed `EuiFlyout` `outsideClickCloses` not being scoped to overlay mask when `ownFocus=true` + From 6b8df0c0661a03be77a34a2b01d69a1ad0de6767 Mon Sep 17 00:00:00 2001 From: Greg Thompson Date: Thu, 5 May 2022 14:34:01 -0500 Subject: [PATCH 5/9] cypress tests --- src/components/flyout/flyout.spec.tsx | 52 ++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/src/components/flyout/flyout.spec.tsx b/src/components/flyout/flyout.spec.tsx index 930dc65f706..f5f6a4651b6 100644 --- a/src/components/flyout/flyout.spec.tsx +++ b/src/components/flyout/flyout.spec.tsx @@ -10,6 +10,7 @@ import React, { useState } from 'react'; +import { EuiGlobalToastList } from '../toast'; import { EuiFlyout } from './flyout'; const childrenDefault = ( @@ -62,7 +63,7 @@ describe('EuiFlyout', () => { }); }); - describe('Close behavior', () => { + describe('Close behavior: standard', () => { it('closes the flyout when the close button is clicked', () => { cy.mount(); cy.realPress('Enter').then(() => { @@ -76,6 +77,37 @@ describe('EuiFlyout', () => { expect(cy.get('[data-test-subj="flyoutSpec"]').should('not.exist')); }); }); + }); + + describe('Close behavior: outside clicks', () => { + const FlyoutWithToasts = ({ children = childrenDefault, ...rest }) => { + const [isOpen, setIsOpen] = useState(true); + + return ( + <> + {isOpen ? ( + setIsOpen(false)} + {...rest} + > + {children} + + ) : null} + {}} + toastLifeTimeMs={10000} + /> + + ); + }; it('closes the flyout when the overlay mask is clicked', () => { cy.mount(); @@ -95,5 +127,23 @@ describe('EuiFlyout', () => { expect(cy.get('[data-test-subj="flyoutSpec"]').should('exist')); }); }); + + it('does not close the flyout when the toast is clicked when `ownFocus=true`', () => { + cy.mount(); + cy.get('[data-test-subj="toastCloseButton"]') + .realClick() + .then(() => { + expect(cy.get('[data-test-subj="flyoutSpec"]').should('exist')); + }); + }); + + it('closes the flyout when the toast is clicked when `ownFocus=false`', () => { + cy.mount(); + cy.get('[data-test-subj="toastCloseButton"]') + .realClick() + .then(() => { + expect(cy.get('[data-test-subj="flyoutSpec"]').should('not.exist')); + }); + }); }); }); From 5d2f52e7ba0c24ed4cfe1fba0ae599e45bcb2a2c Mon Sep 17 00:00:00 2001 From: Greg Thompson Date: Thu, 5 May 2022 15:18:42 -0500 Subject: [PATCH 6/9] use maskRef prop instead of forwardRef --- .../collapsible_nav/collapsible_nav.test.tsx | 8 +- src/components/flyout/flyout.test.tsx | 8 +- src/components/flyout/flyout.tsx | 10 +- src/components/overlay_mask/overlay_mask.tsx | 173 +++++++++--------- 4 files changed, 101 insertions(+), 98 deletions(-) diff --git a/src/components/collapsible_nav/collapsible_nav.test.tsx b/src/components/collapsible_nav/collapsible_nav.test.tsx index 9555ddb1b21..7ccfd6d9f20 100644 --- a/src/components/collapsible_nav/collapsible_nav.test.tsx +++ b/src/components/collapsible_nav/collapsible_nav.test.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { forwardRef as mockForwardRef } from 'react'; +import React from 'react'; import { render, mount } from 'enzyme'; import { requiredProps, takeMountedSnapshot } from '../../test'; @@ -14,10 +14,8 @@ import { EuiCollapsibleNav } from './collapsible_nav'; import { EuiOverlayMaskProps } from '../overlay_mask'; jest.mock('../overlay_mask', () => ({ - EuiOverlayMask: mockForwardRef( - ({ headerZindexLocation, ...props }: any, ref) => ( -
- ) + EuiOverlayMask: ({ headerZindexLocation, maskRef, ...props }: any) => ( +
), })); diff --git a/src/components/flyout/flyout.test.tsx b/src/components/flyout/flyout.test.tsx index a1dc9573e9f..f37e7d1d6aa 100644 --- a/src/components/flyout/flyout.test.tsx +++ b/src/components/flyout/flyout.test.tsx @@ -6,17 +6,15 @@ * Side Public License, v 1. */ -import React, { forwardRef as mockForwardRef } from 'react'; +import React from 'react'; import { render, mount } from 'enzyme'; import { requiredProps, takeMountedSnapshot } from '../../test'; import { EuiFlyout, SIZES, PADDING_SIZES, SIDES } from './flyout'; jest.mock('../overlay_mask', () => ({ - EuiOverlayMask: mockForwardRef( - ({ headerZindexLocation, ...props }: any, ref) => ( -
- ) + EuiOverlayMask: ({ headerZindexLocation, maskRef, ...props }: any) => ( +
), })); diff --git a/src/components/flyout/flyout.tsx b/src/components/flyout/flyout.tsx index ce702174ea2..b49326f3113 100644 --- a/src/components/flyout/flyout.tsx +++ b/src/components/flyout/flyout.tsx @@ -390,13 +390,13 @@ export const EuiFlyout = forwardRef( ); // If ownFocus is set, wrap with an overlay and allow the user to click it to close it. + const mergedMaskProps = { + ...maskProps, + maskRef: useCombinedRefs([maskProps?.maskRef, maskRef]), + }; if (isDefaultConfiguration) { flyout = ( - + {flyout} ); diff --git a/src/components/overlay_mask/overlay_mask.tsx b/src/components/overlay_mask/overlay_mask.tsx index 0d2d6cfbe7e..dc51bb8c0c9 100644 --- a/src/components/overlay_mask/overlay_mask.tsx +++ b/src/components/overlay_mask/overlay_mask.tsx @@ -12,9 +12,11 @@ */ import React, { + FunctionComponent, HTMLAttributes, + MutableRefObject, ReactNode, - forwardRef, + Ref, useEffect, useRef, useState, @@ -37,6 +39,10 @@ export interface EuiOverlayMaskInterface { * Should the mask visually sit above or below the EuiHeader (controlled by z-index) */ headerZindexLocation?: 'above' | 'below'; + /** + * React ref to be passed to the wrapping container + */ + maskRef?: Ref | MutableRefObject; } export type EuiOverlayMaskProps = CommonProps & @@ -46,87 +52,88 @@ export type EuiOverlayMaskProps = CommonProps & > & EuiOverlayMaskInterface; -export const EuiOverlayMask = forwardRef( - ( - { className, children, onClick, headerZindexLocation = 'above', ...rest }, - ref - ) => { - const overlayMaskNode = useRef(); - const combinedMaskRef = useCombinedRefs([overlayMaskNode, ref]); - const [isPortalTargetReady, setIsPortalTargetReady] = useState(false); - - useEffect(() => { - document.body.classList.add('euiBody-hasOverlayMask'); - - return () => { - document.body.classList.remove('euiBody-hasOverlayMask'); - }; - }, []); - - useEffect(() => { - if (typeof document !== 'undefined') { - combinedMaskRef(document.createElement('div')); - } - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - useEffect(() => { - const portalTarget = overlayMaskNode.current; - +export const EuiOverlayMask: FunctionComponent = ({ + className, + children, + onClick, + headerZindexLocation = 'above', + maskRef, + ...rest +}) => { + const overlayMaskNode = useRef(); + const combinedMaskRef = useCombinedRefs([overlayMaskNode, maskRef]); + const [isPortalTargetReady, setIsPortalTargetReady] = useState(false); + + useEffect(() => { + document.body.classList.add('euiBody-hasOverlayMask'); + + return () => { + document.body.classList.remove('euiBody-hasOverlayMask'); + }; + }, []); + + useEffect(() => { + if (typeof document !== 'undefined') { + combinedMaskRef(document.createElement('div')); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + const portalTarget = overlayMaskNode.current; + + if (portalTarget) { + document.body.appendChild(portalTarget); + } + + setIsPortalTargetReady(true); + + return () => { if (portalTarget) { - document.body.appendChild(portalTarget); + document.body.removeChild(portalTarget); } - - setIsPortalTargetReady(true); - - return () => { - if (portalTarget) { - document.body.removeChild(portalTarget); - } - }; - }, []); - - useEffect(() => { - if (!overlayMaskNode.current) return; - keysOf(rest).forEach((key) => { - if (typeof rest[key] !== 'string') { - throw new Error( - `Unhandled property type. EuiOverlayMask property ${key} is not a string.` - ); - } - if (overlayMaskNode.current) { - overlayMaskNode.current.setAttribute(key, rest[key]!); - } - }); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - useEffect(() => { - if (!overlayMaskNode.current) return; - overlayMaskNode.current.className = classNames( - 'euiOverlayMask', - `euiOverlayMask--${headerZindexLocation}Header`, - className - ); - }, [className, headerZindexLocation]); - - useEffect(() => { - const portalTarget = overlayMaskNode.current; - if (!portalTarget || !onClick) return; - - const listener = (e: Event) => { - if (e.target === portalTarget) { - onClick(); - } - }; - portalTarget.addEventListener('click', listener); - - return () => { - portalTarget.removeEventListener('click', listener); - }; - }, [onClick]); - - return isPortalTargetReady ? ( - <>{createPortal(children, overlayMaskNode.current!)} - ) : null; - } -); -EuiOverlayMask.displayName = 'EuiOverlayMask'; + }; + }, []); + + useEffect(() => { + if (!overlayMaskNode.current) return; + keysOf(rest).forEach((key) => { + if (typeof rest[key] !== 'string') { + throw new Error( + `Unhandled property type. EuiOverlayMask property ${key} is not a string.` + ); + } + if (overlayMaskNode.current) { + overlayMaskNode.current.setAttribute(key, rest[key]!); + } + }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!overlayMaskNode.current) return; + overlayMaskNode.current.className = classNames( + 'euiOverlayMask', + `euiOverlayMask--${headerZindexLocation}Header`, + className + ); + }, [className, headerZindexLocation]); + + useEffect(() => { + const portalTarget = overlayMaskNode.current; + if (!portalTarget || !onClick) return; + + const listener = (e: Event) => { + if (e.target === portalTarget) { + onClick(); + } + }; + portalTarget.addEventListener('click', listener); + + return () => { + portalTarget.removeEventListener('click', listener); + }; + }, [onClick]); + + return isPortalTargetReady ? ( + <>{createPortal(children, overlayMaskNode.current!)} + ) : null; +}; From e635a8555b114085353793de81730a49104f497d Mon Sep 17 00:00:00 2001 From: Greg Thompson Date: Thu, 5 May 2022 15:24:37 -0500 Subject: [PATCH 7/9] clean up --- src/components/collapsible_nav/collapsible_nav.test.tsx | 2 +- src/components/flyout/flyout.test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/collapsible_nav/collapsible_nav.test.tsx b/src/components/collapsible_nav/collapsible_nav.test.tsx index 7ccfd6d9f20..d714aa84b26 100644 --- a/src/components/collapsible_nav/collapsible_nav.test.tsx +++ b/src/components/collapsible_nav/collapsible_nav.test.tsx @@ -15,7 +15,7 @@ import { EuiOverlayMaskProps } from '../overlay_mask'; jest.mock('../overlay_mask', () => ({ EuiOverlayMask: ({ headerZindexLocation, maskRef, ...props }: any) => ( -
+
), })); diff --git a/src/components/flyout/flyout.test.tsx b/src/components/flyout/flyout.test.tsx index f37e7d1d6aa..a27ec279443 100644 --- a/src/components/flyout/flyout.test.tsx +++ b/src/components/flyout/flyout.test.tsx @@ -14,7 +14,7 @@ import { EuiFlyout, SIZES, PADDING_SIZES, SIDES } from './flyout'; jest.mock('../overlay_mask', () => ({ EuiOverlayMask: ({ headerZindexLocation, maskRef, ...props }: any) => ( -
+
), })); From d017609ab4a3349509994656341070ff1831fa56 Mon Sep 17 00:00:00 2001 From: Greg Thompson Date: Fri, 6 May 2022 12:43:24 -0500 Subject: [PATCH 8/9] outsideClickCloses=false test; comment --- src/components/flyout/flyout.spec.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/components/flyout/flyout.spec.tsx b/src/components/flyout/flyout.spec.tsx index f5f6a4651b6..881733b5be6 100644 --- a/src/components/flyout/flyout.spec.tsx +++ b/src/components/flyout/flyout.spec.tsx @@ -22,13 +22,17 @@ const childrenDefault = ( ); -const Flyout = ({ children = childrenDefault }) => { +const Flyout = ({ children = childrenDefault, ...rest }) => { const [isOpen, setIsOpen] = useState(true); return ( <> {isOpen ? ( - setIsOpen(false)}> + setIsOpen(false)} + {...rest} + > {children} ) : null} @@ -80,6 +84,8 @@ describe('EuiFlyout', () => { }); describe('Close behavior: outside clicks', () => { + // We're using toasts here to trigger outside clicks, as a UX case where + // we would generally expect toasts overlaid on top of a flyout *not* to close the flyout const FlyoutWithToasts = ({ children = childrenDefault, ...rest }) => { const [isOpen, setIsOpen] = useState(true); @@ -118,6 +124,15 @@ describe('EuiFlyout', () => { }); }); + it('does not close the flyout when `outsideClickCloses=false` and the overlay mask is clicked', () => { + cy.mount(); + cy.get('.euiOverlayMask') + .realClick() + .then(() => { + expect(cy.get('[data-test-subj="flyoutSpec"]').should('exist')); + }); + }); + it('does not close the flyout when the overlay mask is only the target of mouseup', () => { cy.mount(); cy.get('[data-test-subj="itemD"]').realMouseDown().realMouseMove(-100, 0); From 43ce7096c703eea62af32303589f038d80018218 Mon Sep 17 00:00:00 2001 From: Greg Thompson Date: Fri, 6 May 2022 13:46:48 -0500 Subject: [PATCH 9/9] refactor onClickOutside; update name to hasOverlayMask --- src/components/flyout/flyout.tsx | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/components/flyout/flyout.tsx b/src/components/flyout/flyout.tsx index b49326f3113..ecf2612669d 100644 --- a/src/components/flyout/flyout.tsx +++ b/src/components/flyout/flyout.tsx @@ -348,14 +348,20 @@ export const EuiFlyout = forwardRef( ); } - const isDefaultConfiguration = ownFocus && !isPushed; - const onClickOutside = (event: MouseEvent | TouchEvent) => - (isDefaultConfiguration && - outsideClickCloses !== false && - event.target === maskRef.current) || - outsideClickCloses === true - ? onClose(event) - : undefined; + const hasOverlayMask = ownFocus && !isPushed; + const onClickOutside = (event: MouseEvent | TouchEvent) => { + // Do not close the flyout for any external click + if (outsideClickCloses === false) return undefined; + if (hasOverlayMask) { + // The overlay mask is present, so only clicks on the mask should close the flyout, regardless of outsideClickCloses + if (event.target === maskRef.current) return onClose(event); + } else { + // No overlay mask is present, so any outside clicks should close the flyout + if (outsideClickCloses === true) return onClose(event); + } + // Otherwise if ownFocus is false and outsideClickCloses is undefined, outside clicks should not close the flyout + return undefined; + }; /* * Trap focus even when `ownFocus={false}`, otherwise closing * the flyout won't return focus to the originating button. @@ -394,7 +400,7 @@ export const EuiFlyout = forwardRef( ...maskProps, maskRef: useCombinedRefs([maskProps?.maskRef, maskRef]), }; - if (isDefaultConfiguration) { + if (hasOverlayMask) { flyout = ( {flyout}