From 38853f51f04853e42a35b35934b37cd49f5e72a2 Mon Sep 17 00:00:00 2001 From: Cee Chen <549407+cee-chen@users.noreply.github.com> Date: Mon, 8 Jan 2024 09:30:16 -0800 Subject: [PATCH] [EuiFlyoutResizable] Add resizable flyout (#7439) --- changelogs/upcoming/7439.md | 1 + src-docs/src/views/flyout/flyout_example.js | 47 +++++ .../src/views/flyout/flyout_resizable.tsx | 89 ++++++++ .../collapsible_nav_beta.test.tsx.snap | 4 +- src/components/flyout/flyout.tsx | 8 +- .../flyout/flyout_resizable.spec.tsx | 197 ++++++++++++++++++ .../flyout/flyout_resizable.styles.ts | 30 +++ src/components/flyout/flyout_resizable.tsx | 167 +++++++++++++++ src/components/flyout/index.ts | 3 + 9 files changed, 540 insertions(+), 6 deletions(-) create mode 100644 changelogs/upcoming/7439.md create mode 100644 src-docs/src/views/flyout/flyout_resizable.tsx create mode 100644 src/components/flyout/flyout_resizable.spec.tsx create mode 100644 src/components/flyout/flyout_resizable.styles.ts create mode 100644 src/components/flyout/flyout_resizable.tsx diff --git a/changelogs/upcoming/7439.md b/changelogs/upcoming/7439.md new file mode 100644 index 00000000000..53ede186f68 --- /dev/null +++ b/changelogs/upcoming/7439.md @@ -0,0 +1 @@ +- Added a new `EuiFlyoutResizable` component diff --git a/src-docs/src/views/flyout/flyout_example.js b/src-docs/src/views/flyout/flyout_example.js index 5097bfb0c7b..2a894039a23 100644 --- a/src-docs/src/views/flyout/flyout_example.js +++ b/src-docs/src/views/flyout/flyout_example.js @@ -9,6 +9,7 @@ import { EuiFlyoutBody, EuiFlyoutHeader, EuiFlyoutFooter, + EuiFlyoutResizable, EuiCallOut, EuiLink, } from '../../../../src/components'; @@ -37,6 +38,9 @@ const flyoutWithBannerSource = require('!!raw-loader!./flyout_banner'); import FlyoutPush from './flyout_push'; const flyoutPushSource = require('!!raw-loader!./flyout_push'); +import FlyoutResizable from './flyout_resizable'; +const flyoutResizableSource = require('!!raw-loader!./flyout_resizable'); + const flyOutSnippet = ` @@ -144,6 +148,18 @@ const flyoutPushedSnippet = ` `; +const flyoutResizableSnippet = ` + + +

Flyout title

+
+
+ + + +
+`; + export const FlyoutExample = { title: 'Flyout', sections: [ @@ -384,5 +400,36 @@ export const FlyoutExample = { demo: , props: { EuiFlyout }, }, + { + title: 'Resizable flyouts', + isBeta: true, + source: [ + { + type: GuideSectionTypes.JS, + code: flyoutResizableSource, + }, + ], + text: ( + <> +

+ You can use EuiFlyoutResizable to render a flyout + that users can drag with their mouse or use arrow keys to resize. + This may be useful for scenarios where the space the user needs can + be unpredictable, if content is dynamic. Resizable flyouts allow + users to adjust content to better fit their individual screens and + workflows. +

+

+ We strongly recommend setting reasonable numerical{' '} + minWidth and maxWidth props + based on the flyout content and page content that you can{' '} + predict. +

+ + ), + snippet: flyoutResizableSnippet, + demo: , + props: { EuiFlyoutResizable }, + }, ], }; diff --git a/src-docs/src/views/flyout/flyout_resizable.tsx b/src-docs/src/views/flyout/flyout_resizable.tsx new file mode 100644 index 00000000000..4969860ca89 --- /dev/null +++ b/src-docs/src/views/flyout/flyout_resizable.tsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; + +import { + EuiFlyoutResizable, + EuiFlyoutProps, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiButton, + EuiButtonGroup, + EuiText, + EuiTitle, + EuiSpacer, +} from '../../../../src/components'; + +import { useGeneratedHtmlId } from '../../../../src/services'; + +export default () => { + const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + const [flyoutType, setFlyoutType] = useState('overlay'); + const [flyoutSide, setFlyoutSide] = useState('right'); + + const flyoutTitleId = useGeneratedHtmlId({ + prefix: 'simpleFlyoutTitle', + }); + + let flyout; + + if (isFlyoutVisible) { + flyout = ( + setIsFlyoutVisible(false)} + aria-labelledby={flyoutTitleId} + > + + +

A resizable flyout

+
+
+ + +

+ This flyout is resizable by both mouse drag and arrow keys (when + the resizable edge is focused). Both push and overlay flyouts can + be resizable, on either side. +

+
+ + +

Flyout type

+
+ + setFlyoutType(id)} + /> + + +

Flyout side

+
+ setFlyoutSide(id)} + /> +
+
+ ); + } + + return ( + <> + setIsFlyoutVisible(true)}> + Show resizable flyout + + {flyout} + + ); +}; diff --git a/src/components/collapsible_nav_beta/__snapshots__/collapsible_nav_beta.test.tsx.snap b/src/components/collapsible_nav_beta/__snapshots__/collapsible_nav_beta.test.tsx.snap index 28fe32c136d..ba7b15a27bd 100644 --- a/src/components/collapsible_nav_beta/__snapshots__/collapsible_nav_beta.test.tsx.snap +++ b/src/components/collapsible_nav_beta/__snapshots__/collapsible_nav_beta.test.tsx.snap @@ -3,7 +3,7 @@ exports[`EuiCollapsibleNavBeta renders 1`] = `
+/// +/// + +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';