diff --git a/src/components/collapsible_nav/collapsible_nav.spec.tsx b/src/components/collapsible_nav/collapsible_nav.spec.tsx
new file mode 100644
index 00000000000..d32574e0057
--- /dev/null
+++ b/src/components/collapsible_nav/collapsible_nav.spec.tsx
@@ -0,0 +1,83 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+///
+
+import React, { useState } from 'react';
+
+import { EuiCollapsibleNav } from './collapsible_nav';
+import { EuiHeader, EuiHeaderSectionItemButton } from '../header';
+import { EuiIcon } from '../icon';
+
+const Nav = () => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+ setIsOpen(!isOpen)}
+ >
+
+
+ }
+ onClose={() => setIsOpen(false)}
+ >
+
+
+
+
+ ,
+ ],
+ },
+ ]}
+ />
+ );
+};
+
+describe('EuiCollapsibleNav', () => {
+ describe('Elastic pattern', () => {
+ describe('Toggle button behavior', () => {
+ it('opens and closes nav when the main button is clicked', () => {
+ cy.mount();
+ cy.wait(400); // Wait for the button to be clickable
+ cy.get('[data-test-subj="navSpecButton"]').realClick();
+ expect(cy.get('#navSpec').should('exist'));
+ cy.get('[data-test-subj="navSpecButton"]').realClick();
+ expect(cy.get('#navSpec').should('not.exist'));
+ });
+
+ it('closes the nav when the overlay mask is clicked', () => {
+ cy.mount();
+ cy.wait(400);
+ cy.get('[data-test-subj="navSpecButton"]').realClick();
+ cy.get('.euiOverlayMask').realClick();
+ expect(cy.get('#navSpec').should('not.exist'));
+ });
+
+ it('closes the nav when the close button is clicked', () => {
+ cy.mount();
+ cy.wait(400);
+ cy.get('[data-test-subj="navSpecButton"]').realClick();
+ cy.get('[data-test-subj="euiFlyoutCloseButton"]').realClick();
+ expect(cy.get('#navSpec').should('not.exist'));
+ });
+ });
+ });
+});
diff --git a/src/components/collapsible_nav/collapsible_nav.tsx b/src/components/collapsible_nav/collapsible_nav.tsx
index 2531f2a5021..ffac8a407e9 100644
--- a/src/components/collapsible_nav/collapsible_nav.tsx
+++ b/src/components/collapsible_nav/collapsible_nav.tsx
@@ -12,6 +12,7 @@ import React, {
ReactElement,
ReactNode,
useEffect,
+ useRef,
useState,
} from 'react';
import classNames from 'classnames';
@@ -19,6 +20,7 @@ import {
useGeneratedHtmlId,
isWithinMinBreakpoint,
throttle,
+ useCombinedRefs,
} from '../../services';
import { EuiFlyout, EuiFlyoutProps } from '../flyout';
@@ -71,12 +73,19 @@ export const EuiCollapsibleNav: FunctionComponent = ({
outsideClickCloses = true,
closeButtonPosition = 'outside',
paddingSize = 'none',
+ focusTrapProps: _focusTrapProps = {},
...rest
}) => {
const flyoutID = useGeneratedHtmlId({
conditionalId: id,
suffix: 'euiCollapsibleNav',
});
+ const buttonRef = useRef();
+ const combinedButtonRef = useCombinedRefs([button?.props.ref, buttonRef]);
+ const focusTrapProps: EuiFlyoutProps['focusTrapProps'] = {
+ ..._focusTrapProps,
+ shards: [buttonRef, ...(_focusTrapProps.shards || [])],
+ };
/**
* Setting the initial state of pushed based on the `type` prop
@@ -136,6 +145,7 @@ export const EuiCollapsibleNav: FunctionComponent = ({
onMouseUpCapture: (e: React.MouseEvent) => {
e.nativeEvent.stopImmediatePropagation();
},
+ ref: combinedButtonRef,
});
const flyout = (
@@ -151,6 +161,7 @@ export const EuiCollapsibleNav: FunctionComponent = ({
outsideClickCloses={outsideClickCloses}
closeButtonPosition={closeButtonPosition}
paddingSize={paddingSize}
+ focusTrapProps={focusTrapProps}
{...rest}
// Props dependent on internal docked status
type={navIsDocked ? 'push' : 'overlay'}
diff --git a/src/components/flyout/flyout.tsx b/src/components/flyout/flyout.tsx
index 79aaa35ad0d..f79f5b5a241 100644
--- a/src/components/flyout/flyout.tsx
+++ b/src/components/flyout/flyout.tsx
@@ -30,7 +30,7 @@ import {
} from '../../services';
import { CommonProps, keysOf, PropsOfElement } from '../common';
-import { EuiFocusTrap } from '../focus_trap';
+import { EuiFocusTrap, EuiFocusTrapProps } from '../focus_trap';
import { EuiOverlayMask, EuiOverlayMaskProps } from '../overlay_mask';
import { EuiButtonIcon, EuiButtonIconPropsForButton } from '../button';
import { EuiI18n } from '../i18n';
@@ -151,6 +151,12 @@ interface _EuiFlyoutProps {
*/
pushMinBreakpoint?: EuiBreakpointSize | number;
style?: CSSProperties;
+ /**
+ * Object of props passed to EuiFocusTrap.
+ * `shards` specifies an array of elements that will be considered part of the flyout, preventing the flyout from being closed when clicked.
+ * `closeOnMouseup` will delay the close callback, allowing time for external toggle buttons to handle close behavior.
+ */
+ focusTrapProps?: Pick;
}
const defaultElement = 'div';
@@ -189,6 +195,7 @@ export const EuiFlyout = forwardRef(
outsideClickCloses,
role = 'dialog',
pushMinBreakpoint = 'l',
+ focusTrapProps,
...rest
}: EuiFlyoutProps,
ref:
@@ -362,6 +369,7 @@ export const EuiFlyout = forwardRef(
disabled={isPushed}
clickOutsideDisables={!ownFocus}
onClickOutside={onClickOutside}
+ {...focusTrapProps}
>
)}
diff --git a/src/components/focus_trap/focus_trap.spec.tsx b/src/components/focus_trap/focus_trap.spec.tsx
index 7687438a486..d8a8988ac9e 100644
--- a/src/components/focus_trap/focus_trap.spec.tsx
+++ b/src/components/focus_trap/focus_trap.spec.tsx
@@ -8,7 +8,7 @@
///
-import React from 'react';
+import React, { useRef } from 'react';
import { EuiFocusTrap } from './focus_trap';
import { EuiPortal } from '../portal';
@@ -157,4 +157,80 @@ describe('EuiFocusTrap', () => {
cy.get('[data-focus-lock-disabled=false]').should('not.exist');
});
});
+
+ describe('outside click handling', () => {
+ const Trap = ({
+ onClickOutside,
+ shards,
+ closeOnMouseup,
+ }: {
+ onClickOutside?: any;
+ shards?: boolean;
+ closeOnMouseup?: boolean;
+ }) => {
+ const buttonRef = useRef();
+ return (
+
+ );
+ };
+
+ it('calls the callback on mousedown', () => {
+ const onClickOutside = cy.stub();
+ cy.mount();
+
+ cy.get('[data-test-subj=outside]')
+ .realMouseDown()
+ .then(() => {
+ expect(onClickOutside).to.be.called;
+ });
+ });
+
+ it('calls the callback on mouseup when using closeOnMouseup', () => {
+ const onClickOutside = cy.stub();
+ cy.mount();
+
+ cy.get('[data-test-subj=outside]')
+ .realMouseDown()
+ .then(() => {
+ expect(onClickOutside).to.not.be.called;
+ });
+ cy.get('[data-test-subj=outside]')
+ .click() // real events not working here
+ .then(() => {
+ expect(onClickOutside).to.be.called;
+ });
+ });
+
+ it('does not call the callback if the element is a shard', () => {
+ const onClickOutside = cy.stub();
+ cy.mount();
+
+ cy.get('[data-test-subj=outside]')
+ .realMouseDown()
+ .then(() => {
+ expect(onClickOutside).to.not.be.called;
+ });
+ // But still calls if the element is not a shard
+ cy.get('[data-test-subj=outside2]')
+ .realMouseDown()
+ .then(() => {
+ expect(onClickOutside).to.be.called;
+ });
+ });
+ });
});
diff --git a/src/components/focus_trap/focus_trap.tsx b/src/components/focus_trap/focus_trap.tsx
index fc67ba70f2b..b210e9f902c 100644
--- a/src/components/focus_trap/focus_trap.tsx
+++ b/src/components/focus_trap/focus_trap.tsx
@@ -16,6 +16,11 @@ import { findElementBySelectorOrRef, ElementTarget } from '../../services';
export type FocusTarget = ElementTarget;
interface EuiFocusTrapInterface {
+ /**
+ * Whether `onClickOutside` should be called on mouseup instead of mousedown.
+ * This flag can be used to prevent conflicts with outside toggle buttons by delaying the closing click callback.
+ */
+ closeOnMouseup?: boolean;
/**
* Clicking outside the trap area will disable the trap
*/
@@ -64,6 +69,10 @@ export class EuiFocusTrap extends Component {
}
}
+ componentWillUnmount() {
+ this.removeMouseupListener();
+ }
+
// Programmatically sets focus on a nested DOM node; optional
setInitialFocus = (initialFocus?: FocusTarget) => {
const node = findElementBySelectorOrRef(initialFocus);
@@ -72,14 +81,31 @@ export class EuiFocusTrap extends Component {
node.setAttribute('data-autofocus', 'true');
};
+ 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.
+ setTimeout(() => this.props.onClickOutside?.(e));
+ };
+
+ addMouseupListener = () => {
+ document.addEventListener('mouseup', this.onMouseupOutside);
+ document.addEventListener('touchend', this.onMouseupOutside);
+ };
+
+ removeMouseupListener = () => {
+ document.removeEventListener('mouseup', this.onMouseupOutside);
+ document.removeEventListener('touchend', this.onMouseupOutside);
+ };
+
handleOutsideClick: ReactFocusOnProps['onClickOutside'] = (...args) => {
- const { onClickOutside, clickOutsideDisables } = this.props;
+ const { onClickOutside, clickOutsideDisables, closeOnMouseup } = this.props;
if (clickOutsideDisables) {
this.setState({ hasBeenDisabledByClick: true });
}
if (onClickOutside) {
- onClickOutside(...args);
+ closeOnMouseup ? this.addMouseupListener() : onClickOutside(...args);
}
};
diff --git a/upcoming_changelogs/5860.md b/upcoming_changelogs/5860.md
new file mode 100644
index 00000000000..172fbf6f29f
--- /dev/null
+++ b/upcoming_changelogs/5860.md
@@ -0,0 +1,6 @@
+- Added the `focusTrapProps` prop to `EuiFlyout` to aid outside click detection and closing event
+
+**Bug fixes**
+
+- Fixed `EuiCollapsibleNav` failing to close when the button is clicked
+