+///
+///
+
+import React from 'react';
+
+import { EuiFlyoutResizable } from './flyout_resizable';
+
+const onClose = () => {};
+
+describe('EuiFlyoutResizable', () => {
+ beforeEach(() => {
+ cy.viewport(1200, 500);
+ });
+
+ describe('sets the flyout size after initial load to a static number', () => {
+ it('default size', () => {
+ cy.mount(
);
+ cy.get('.euiFlyout').should('have.css', 'inline-size', '600px');
+ });
+
+ it('size enum', () => {
+ cy.mount(
);
+ cy.get('.euiFlyout').should('have.css', 'inline-size', '384px');
+ });
+
+ it('number', () => {
+ cy.mount(
);
+ cy.get('.euiFlyout').should('have.css', 'inline-size', '300px');
+ });
+
+ it('CSS width', () => {
+ cy.mount(
);
+ cy.get('.euiFlyout').should('have.css', 'inline-size', '960px');
+ });
+
+ it('with max width', () => {
+ cy.mount(
+
+ );
+ cy.get('.euiFlyout').should('have.css', 'inline-size', '400px');
+ });
+
+ it('with min width', () => {
+ cy.mount(
+
+ );
+ cy.get('.euiFlyout').should('have.css', 'inline-size', '200px');
+ });
+ });
+
+ describe('resizing', () => {
+ // There isn't a way to actually drag & drop in Cypress, so we're mocking it via triggers
+ it('mouse drag', () => {
+ cy.mount(
);
+ cy.get('[data-test-subj="euiResizableButton"]')
+ .trigger('mousedown', { pageX: 400 })
+ .trigger('mousemove', { pageX: 600 });
+ cy.get('.euiFlyout').should('have.css', 'inline-size', '600px');
+
+ cy.get('[data-test-subj="euiResizableButton"]').trigger('mousemove', {
+ pageX: 200,
+ });
+ cy.get('.euiFlyout').should('have.css', 'inline-size', '1000px');
+
+ // Should not change the flyout width if not dragging
+ cy.get('[data-test-subj="euiResizableButton"]')
+ .trigger('mouseup')
+ .trigger('mousemove', { pageX: 1000 });
+ cy.get('.euiFlyout').should('have.css', 'inline-size', '1000px');
+ });
+
+ it('mobile touch drag', () => {
+ cy.mount(
);
+ cy.get('[data-test-subj="euiResizableButton"]')
+ .trigger('touchstart', { targetTouches: [{ pageX: 400 }], touches: [] })
+ .trigger('touchmove', { targetTouches: [{ pageX: 800 }], touches: [] })
+ .trigger('touchend', { touches: [] });
+ cy.get('.euiFlyout').should('have.css', 'inline-size', '400px');
+ });
+
+ it('keyboard tabbing', () => {
+ cy.mount(
);
+ cy.get('[data-test-subj="euiResizableButton"]').focus();
+
+ cy.repeatRealPress('ArrowRight', 10);
+ cy.get('.euiFlyout').should('have.css', 'inline-size', '700px');
+
+ cy.repeatRealPress('ArrowLeft', 5);
+ cy.get('.euiFlyout').should('have.css', 'inline-size', '750px');
+ });
+
+ it('does not allow the flyout to be resized past the window width', () => {
+ cy.mount(
);
+ cy.get('[data-test-subj="euiResizableButton"]')
+ .trigger('mousedown', { pageX: 400 })
+ .trigger('mousemove', { pageX: -100 });
+ cy.get('.euiFlyout').should('have.css', 'inline-size', '1180px');
+ });
+
+ it('does not allow the flyout to be resized past the max width', () => {
+ cy.mount(
+
+ );
+ cy.get('[data-test-subj="euiResizableButton"]')
+ .trigger('mousedown', { pageX: 400 })
+ .trigger('mousemove', { pageX: 100 });
+ cy.get('.euiFlyout').should('have.css', 'inline-size', '1000px');
+ });
+
+ it('does not allow the flyout to be resized past the min width', () => {
+ cy.mount(
+
+ );
+ cy.get('[data-test-subj="euiResizableButton"]')
+ .trigger('mousedown', { pageX: 400 })
+ .trigger('mousemove', { pageX: 2000 });
+ cy.get('.euiFlyout').should('have.css', 'inline-size', '100px');
+ });
+
+ describe('direction', () => {
+ it('reverses the calculations for left side flyouts', () => {
+ cy.mount(
+
+ );
+ assertReversedDirections();
+ });
+
+ it('reverses again for RTL logical property directions', () => {
+ cy.mount(
+
+ );
+ assertReversedDirections({ force: true });
+ });
+
+ const assertReversedDirections = (options?: { force: boolean }) => {
+ cy.get('[data-test-subj="euiResizableButton"]').focus();
+
+ cy.repeatRealPress('ArrowRight', 10);
+ cy.get('.euiFlyout').should('have.css', 'inline-size', '900px');
+
+ cy.repeatRealPress('ArrowLeft', 5);
+ cy.get('.euiFlyout').should('have.css', 'inline-size', '850px');
+
+ cy.get('[data-test-subj="euiResizableButton"]')
+ .trigger('mousedown', { pageX: 850, ...options })
+ .trigger('mousemove', { pageX: 400, ...options });
+ cy.get('.euiFlyout').should('have.css', 'inline-size', '400px');
+ };
+ });
+ });
+
+ describe('push flyouts', () => {
+ it('correctly updates the body padding offset on resize', () => {
+ cy.mount(
);
+ cy.get('body').should('have.css', 'padding-inline-end', '800px');
+
+ cy.get('[data-test-subj="euiResizableButton"]')
+ .trigger('mousedown', { pageX: 400 })
+ .trigger('mousemove', { pageX: 1000 });
+
+ cy.get('.euiFlyout').should('have.css', 'inline-size', '200px');
+ cy.get('body').should('have.css', 'padding-inline-end', '200px');
+ });
+
+ it('handles left side push flyouts', () => {
+ cy.mount(
+
+ );
+ cy.get('body').should('have.css', 'padding-inline-start', '800px');
+
+ cy.get('[data-test-subj="euiResizableButton"]')
+ .trigger('mousedown', { pageX: 800 })
+ .trigger('mousemove', { pageX: 200 });
+
+ cy.get('.euiFlyout').should('have.css', 'inline-size', '200px');
+ cy.get('body').should('have.css', 'padding-inline-start', '200px');
+ });
+ });
+});
diff --git a/src/components/flyout/flyout_resizable.styles.ts b/src/components/flyout/flyout_resizable.styles.ts
new file mode 100644
index 00000000000..abcda128fef
--- /dev/null
+++ b/src/components/flyout/flyout_resizable.styles.ts
@@ -0,0 +1,30 @@
+/*
+ * 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 { css } from '@emotion/react';
+
+import { UseEuiTheme } from '../../services';
+import { logicalCSS } from '../../global_styling';
+
+export const euiFlyoutResizableButtonStyles = ({ euiTheme }: UseEuiTheme) => ({
+ euiFlyoutResizableButton: css`
+ position: absolute;
+
+ /* Hide the default grab icon (although the hover/focus states should remain) */
+ &::before,
+ &::after {
+ background-color: transparent;
+ }
+ `,
+ left: css`
+ ${logicalCSS('right', `-${euiTheme.border.width.thin}`)}
+ `,
+ right: css`
+ ${logicalCSS('left', `-${euiTheme.border.width.thin}`)}
+ `,
+});
diff --git a/src/components/flyout/flyout_resizable.tsx b/src/components/flyout/flyout_resizable.tsx
new file mode 100644
index 00000000000..0c97713edb7
--- /dev/null
+++ b/src/components/flyout/flyout_resizable.tsx
@@ -0,0 +1,167 @@
+/*
+ * 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, {
+ forwardRef,
+ useState,
+ useEffect,
+ useRef,
+ useMemo,
+ useCallback,
+} from 'react';
+
+import { keys, useCombinedRefs, useEuiTheme } from '../../services';
+import { EuiResizableButton } from '../resizable_container';
+
+import { EuiFlyout, EuiFlyoutProps } from './flyout';
+import { euiFlyoutResizableButtonStyles } from './flyout_resizable.styles';
+
+export type EuiFlyoutResizableProps = Omit
& {
+ maxWidth?: number;
+ minWidth?: number;
+};
+
+export const EuiFlyoutResizable = forwardRef(
+ (
+ {
+ size,
+ maxWidth,
+ minWidth = 200,
+ side = 'right',
+ children,
+ ...rest
+ }: EuiFlyoutResizableProps,
+ ref
+ ) => {
+ const euiTheme = useEuiTheme();
+ const styles = euiFlyoutResizableButtonStyles(euiTheme);
+ const cssStyles = [styles.euiFlyoutResizableButton, styles[side]];
+
+ const getFlyoutMinMaxWidth = useCallback(
+ (width: number) => {
+ return Math.min(
+ Math.max(width, minWidth),
+ maxWidth || Infinity,
+ window.innerWidth - 20 // Leave some offset
+ );
+ },
+ [minWidth, maxWidth]
+ );
+
+ const [flyoutWidth, setFlyoutWidth] = useState(0);
+
+ // Must use state for the flyout ref in order for the useEffect to be correctly called after render
+ const [flyoutRef, setFlyoutRef] = useState(null);
+ const setRefs = useCombinedRefs([setFlyoutRef, ref]);
+ useEffect(() => {
+ setFlyoutWidth(
+ flyoutRef ? getFlyoutMinMaxWidth(flyoutRef.offsetWidth) : 0
+ );
+ }, [flyoutRef, getFlyoutMinMaxWidth, size]);
+
+ // Initial numbers to calculate from, on resize drag start
+ const initialWidth = useRef(0);
+ const initialMouseX = useRef(0);
+
+ // Account for flyout side and logical property direction
+ const direction = useMemo(() => {
+ let modifier = side === 'right' ? -1 : 1;
+ if (flyoutRef) {
+ const languageDirection = window.getComputedStyle(flyoutRef).direction;
+ if (languageDirection === 'rtl') modifier *= -1;
+ }
+ return modifier;
+ }, [side, flyoutRef]);
+
+ const onMouseMove = useCallback(
+ (e: MouseEvent | TouchEvent) => {
+ const mouseOffset = getMouseOrTouchX(e) - initialMouseX.current;
+ const changedFlyoutWidth =
+ initialWidth.current + mouseOffset * direction;
+
+ setFlyoutWidth(getFlyoutMinMaxWidth(changedFlyoutWidth));
+ },
+ [getFlyoutMinMaxWidth, direction]
+ );
+
+ const onMouseUp = useCallback(() => {
+ initialMouseX.current = 0;
+
+ window.removeEventListener('mousemove', onMouseMove);
+ window.removeEventListener('mouseup', onMouseUp);
+ window.removeEventListener('touchmove', onMouseMove);
+ window.removeEventListener('touchend', onMouseUp);
+ }, [onMouseMove]);
+
+ const onMouseDown = useCallback(
+ (e: React.MouseEvent | React.TouchEvent) => {
+ initialMouseX.current = getMouseOrTouchX(e);
+ initialWidth.current = flyoutRef?.offsetWidth ?? 0;
+
+ // Window event listeners instead of React events are used
+ // in case the user's mouse leaves the component
+ window.addEventListener('mousemove', onMouseMove);
+ window.addEventListener('mouseup', onMouseUp);
+ window.addEventListener('touchmove', onMouseMove);
+ window.addEventListener('touchend', onMouseUp);
+ },
+ [flyoutRef, onMouseMove, onMouseUp]
+ );
+
+ const onKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ const KEYBOARD_OFFSET = 10;
+
+ switch (e.key) {
+ case keys.ARROW_RIGHT:
+ e.preventDefault(); // Safari+VO will screen reader navigate off the button otherwise
+ setFlyoutWidth((flyoutWidth) =>
+ getFlyoutMinMaxWidth(flyoutWidth + KEYBOARD_OFFSET * direction)
+ );
+ break;
+ case keys.ARROW_LEFT:
+ e.preventDefault(); // Safari+VO will screen reader navigate off the button otherwise
+ setFlyoutWidth((flyoutWidth) =>
+ getFlyoutMinMaxWidth(flyoutWidth - KEYBOARD_OFFSET * direction)
+ );
+ }
+ },
+ [getFlyoutMinMaxWidth, direction]
+ );
+
+ return (
+
+
+ {children}
+
+ );
+ }
+);
+EuiFlyoutResizable.displayName = 'EuiFlyoutResizable';
+
+const getMouseOrTouchX = (
+ e: TouchEvent | MouseEvent | React.MouseEvent | React.TouchEvent
+): number => {
+ // Some Typescript fooling is needed here
+ const x = (e as TouchEvent).targetTouches
+ ? (e as TouchEvent).targetTouches[0].pageX
+ : (e as MouseEvent).pageX;
+ return x;
+};
diff --git a/src/components/flyout/index.ts b/src/components/flyout/index.ts
index e1cd2bed913..aa80a23d85a 100644
--- a/src/components/flyout/index.ts
+++ b/src/components/flyout/index.ts
@@ -19,3 +19,6 @@ export type { EuiFlyoutHeaderProps } from './flyout_header';
export { EuiFlyoutHeader } from './flyout_header';
export { euiFlyoutSlideInRight, euiFlyoutSlideInLeft } from './flyout.styles';
+
+export type { EuiFlyoutResizableProps } from './flyout_resizable';
+export { EuiFlyoutResizable } from './flyout_resizable';