From 0a1b43d110938d053bd73a0eb845e01a1a830563 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Tue, 1 Aug 2023 17:03:59 -0700 Subject: [PATCH] Implement mobile/responsive logic - on desktop, collapsing retains the flyout but in an icon only button state that toggles popovers for accordions - on small screens, the flyout reverts to an overlay and fully disappears when collapsed - on smallest possible screens, the flyout becomes 100% width (requires an `!important` to override EuiFlyout's default CSS) --- .../collapsible_nav_beta.test.tsx.snap | 153 ++++++++++++++++++ .../collapsible_nav_beta.styles.ts | 6 + .../collapsible_nav_beta.test.tsx | 66 +++++++- .../collapsible_nav_beta.tsx | 69 +++++++- .../collapsible_nav_button.test.tsx.snap | 44 +++++ .../collapsible_nav_button.test.tsx | 56 ++++++- .../collapsible_nav_button.tsx | 14 +- 7 files changed, 388 insertions(+), 20 deletions(-) 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 af1ca3ccddd..aca056d98d6 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 @@ -55,3 +55,156 @@ exports[`EuiCollapsibleNavBeta renders 1`] = ` `; + +exports[`EuiCollapsibleNavBeta renders initialIsCollapsed 1`] = ` + +
+
+ +
+
+
+ +
+
+
+ +`; + +exports[`EuiCollapsibleNavBeta responsive behavior collapses from a push flyout to an overlay flyout once the screen is smaller than 3x the flyout width 1`] = ` + +
+
+ +
+
+ +`; + +exports[`EuiCollapsibleNavBeta responsive behavior makes the overlay flyout full width once the screen is smaller than 1.5x the flyout width 1`] = ` + +
+
+ +
+
+
+
+
+ +
+
+
+ +`; diff --git a/src/components/collapsible_nav_beta/collapsible_nav_beta.styles.ts b/src/components/collapsible_nav_beta/collapsible_nav_beta.styles.ts index cd5ce5e786c..28d39cadbd7 100644 --- a/src/components/collapsible_nav_beta/collapsible_nav_beta.styles.ts +++ b/src/components/collapsible_nav_beta/collapsible_nav_beta.styles.ts @@ -26,5 +26,11 @@ export const euiCollapsibleNavBetaStyles = (euiThemeContext: UseEuiTheme) => { right: css` ${logicalCSS('border-left', euiTheme.border.thin)} `, + isSmallestScreen: css` + /* Override EuiFlyout's max-width */ + &.euiFlyout { + ${logicalCSS('max-width', '100% !important')} + } + `, }; }; diff --git a/src/components/collapsible_nav_beta/collapsible_nav_beta.test.tsx b/src/components/collapsible_nav_beta/collapsible_nav_beta.test.tsx index 0aadd058568..d40fcd8d694 100644 --- a/src/components/collapsible_nav_beta/collapsible_nav_beta.test.tsx +++ b/src/components/collapsible_nav_beta/collapsible_nav_beta.test.tsx @@ -7,6 +7,7 @@ */ import React from 'react'; +import { fireEvent } from '@testing-library/react'; import { render } from '../../test/rtl'; import { shouldRenderCustomStyles } from '../../test/internal'; import { requiredProps } from '../../test'; @@ -29,23 +30,24 @@ describe('EuiCollapsibleNavBeta', () => { }); it('renders initialIsCollapsed', () => { - const { queryByTestSubject } = render( + const { baseElement, getByTestSubject } = render( Nav content ); - expect(queryByTestSubject('nav')).not.toBeInTheDocument(); + expect(getByTestSubject('nav')).toHaveStyle({ 'inline-size': '48px' }); + expect(baseElement).toMatchSnapshot(); }); it('toggles collapsed state', () => { - const { getByTestSubject, queryByTestSubject } = render( + const { getByTestSubject } = render( Nav content ); - expect(queryByTestSubject('nav')).toBeInTheDocument(); + expect(getByTestSubject('nav')).toHaveStyle({ 'inline-size': '248px' }); fireEvent.click(getByTestSubject('euiCollapsibleNavButton')); - expect(queryByTestSubject('nav')).not.toBeInTheDocument(); + expect(getByTestSubject('nav')).toHaveStyle({ 'inline-size': '48px' }); }); it('automatically accounts for fixed EuiHeaders in its positioning', () => { @@ -62,5 +64,59 @@ describe('EuiCollapsibleNavBeta', () => { }); }); + describe('responsive behavior', () => { + const mockWindowResize = (width: number) => { + window.innerWidth = width; + window.dispatchEvent(new Event('resize')); + }; + + it('collapses from a push flyout to an overlay flyout once the screen is smaller than 3x the flyout width', () => { + mockWindowResize(600); + const { baseElement } = render( + Nav content + ); + expect(baseElement).toMatchSnapshot(); + }); + + it('makes the overlay flyout full width once the screen is smaller than 1.5x the flyout width', () => { + mockWindowResize(320); + const { baseElement, getByTestSubject } = render( + Nav content + ); + fireEvent.click(getByTestSubject('euiCollapsibleNavButton')); + expect(baseElement).toMatchSnapshot(); + + // onClose testing + expect( + baseElement.querySelector('[data-euiicon-type="cross"') + ).toBeInTheDocument(); + fireEvent.keyDown(window, { key: 'Escape' }); + expect( + baseElement.querySelector('[data-euiicon-type="menu"') + ).toBeInTheDocument(); + }); + + it('adjusts breakpoints for custom widths', () => { + mockWindowResize(1600); + const desktop = render( + + Nav content + + ); + expect(desktop.getByTestSubject('pushFlyout')).toBeInTheDocument(); + desktop.unmount(); + + mockWindowResize(1200); + const mobile = render( + + Nav content + + ); + expect( + mobile.queryByTestSubject('overlayFlyout') + ).not.toBeInTheDocument(); + }); + }); + // TODO: Visual snapshot for left vs right `side` prop, once we add visual snapshot testing }); diff --git a/src/components/collapsible_nav_beta/collapsible_nav_beta.tsx b/src/components/collapsible_nav_beta/collapsible_nav_beta.tsx index 803412ee114..1623e6c0d31 100644 --- a/src/components/collapsible_nav_beta/collapsible_nav_beta.tsx +++ b/src/components/collapsible_nav_beta/collapsible_nav_beta.tsx @@ -18,7 +18,7 @@ import React, { } from 'react'; import classNames from 'classnames'; -import { useEuiTheme, useGeneratedHtmlId } from '../../services'; +import { useEuiTheme, useGeneratedHtmlId, throttle } from '../../services'; import { mathWithUnits, logicalStyle } from '../../global_styling'; import { CommonProps } from '../common'; @@ -43,6 +43,21 @@ export type EuiCollapsibleNavBetaProps = CommonProps & * Whether the navigation flyout should default to initially collapsed or expanded */ initialIsCollapsed?: boolean; + /** + * Defaults to 248px wide. The navigation width determines behavior at + * various responsive breakpoints. + * + * At larger screen sizes (at least 3x the width of the nav), the nav will + * be able to be toggled between a docked full width nav and a collapsed + * side bar that only shows the icon of each item. + * + * At under 3 times the width of the nav, the behavior will lose the collapsed + * side bar behavior, and switch from a docked flyout to an overlay flyout only. + * + * If the page is under 1.5 times the width of the nav, the overlay will + * take up the full width of the page. + */ + width?: number; }; export const EuiCollapsibleNavBeta: FunctionComponent< @@ -53,6 +68,7 @@ export const EuiCollapsibleNavBeta: FunctionComponent< className, style, initialIsCollapsed = false, + width: _width = 248, side = 'left', focusTrapProps: _focusTrapProps, ...rest @@ -70,6 +86,42 @@ export const EuiCollapsibleNavBeta: FunctionComponent< ); const onClose = useCallback(() => setIsCollapsed(true), []); + /** + * Mobile behavior + */ + const [isSmallScreen, setIsSmallScreen] = useState(false); + const [isSmallestScreen, setIsSmallestScreen] = useState(false); + + // Add a window resize listener that determines breakpoint behavior + useEffect(() => { + const getBreakpoints = () => { + setIsSmallScreen(window.innerWidth < _width * 3); + setIsSmallestScreen(window.innerWidth < _width * 1.5); + }; + getBreakpoints(); + + const onWindowResize = throttle(getBreakpoints, 50); + window.addEventListener('resize', onWindowResize); + return () => window.removeEventListener('resize', onWindowResize); + }, [_width]); + + // If the screen was previously uncollapsed and shrinks down to + // a smaller mobile view, default that view to a collapsed state + useEffect(() => { + if (isSmallScreen) setIsCollapsed(true); + }, [isSmallScreen]); + + // On small screens, the flyout becomes an overlay rather than a push + const flyoutType = isSmallScreen ? 'overlay' : 'push'; + const isMobileCollapsed = isSmallScreen && isCollapsed; + + const width = useMemo(() => { + if (isSmallestScreen) return '100%'; + if (isSmallScreen) return _width; + if (isCollapsed) return headerHeight; + return _width; + }, [_width, isSmallScreen, isSmallestScreen, isCollapsed, headerHeight]); + /** * Header affordance */ @@ -119,7 +171,11 @@ export const EuiCollapsibleNavBeta: FunctionComponent< className ); const styles = euiCollapsibleNavBetaStyles(euiTheme); - const cssStyles = [styles.euiCollapsibleNavBeta, styles[side]]; + const cssStyles = [ + styles.euiCollapsibleNavBeta, + styles[side], + isSmallestScreen && styles.isSmallestScreen, + ]; // Wait for any fixed headers to be queried before rendering (prevents position jumping) const flyout = fixedHeadersCount !== false && ( @@ -129,13 +185,13 @@ export const EuiCollapsibleNavBeta: FunctionComponent< css={cssStyles} className={classes} style={stylesWithHeaderOffset} - size={248} // TODO: Responsive behavior + size={width} side={side} focusTrapProps={focusTrapProps} as="nav" - type="push" // TODO: Responsive behavior + type={flyoutType} paddingSize="none" - pushMinBreakpoint="s" + pushMinBreakpoint="xs" onClose={onClose} hideCloseButton={true} > @@ -150,10 +206,11 @@ export const EuiCollapsibleNavBeta: FunctionComponent< ref={buttonRef} onClick={toggleCollapsed} isCollapsed={isCollapsed} + isSmallScreen={isSmallScreen} side={side} aria-controls={flyoutID} /> - {!isCollapsed && flyout} + {!isMobileCollapsed && flyout} ); }; diff --git a/src/components/collapsible_nav_beta/collapsible_nav_button/__snapshots__/collapsible_nav_button.test.tsx.snap b/src/components/collapsible_nav_beta/collapsible_nav_button/__snapshots__/collapsible_nav_button.test.tsx.snap index 1073981fa84..8978fed5ba9 100644 --- a/src/components/collapsible_nav_beta/collapsible_nav_button/__snapshots__/collapsible_nav_button.test.tsx.snap +++ b/src/components/collapsible_nav_beta/collapsible_nav_button/__snapshots__/collapsible_nav_button.test.tsx.snap @@ -87,3 +87,47 @@ exports[`EuiCollapsibleNavButton desktop right side renders a menu right icon wh
`; + +exports[`EuiCollapsibleNavButton mobile renders a hamburger icon when collapsed 1`] = ` +
+ +
+`; + +exports[`EuiCollapsibleNavButton mobile renders an X icon when expanded 1`] = ` +
+ +
+`; diff --git a/src/components/collapsible_nav_beta/collapsible_nav_button/collapsible_nav_button.test.tsx b/src/components/collapsible_nav_beta/collapsible_nav_button/collapsible_nav_button.test.tsx index dd36f2d3c53..9c30df2390c 100644 --- a/src/components/collapsible_nav_beta/collapsible_nav_button/collapsible_nav_button.test.tsx +++ b/src/components/collapsible_nav_beta/collapsible_nav_button/collapsible_nav_button.test.tsx @@ -16,7 +16,11 @@ describe('EuiCollapsibleNavButton', () => { describe('left side', () => { it('renders a menu left icon when expanded', () => { const { container } = render( - + ); expect(container.firstChild).toMatchSnapshot(); @@ -27,7 +31,11 @@ describe('EuiCollapsibleNavButton', () => { it('renders a menu right icon when collapsed', () => { const { container } = render( - + ); expect(container.firstChild).toMatchSnapshot(); @@ -40,7 +48,11 @@ describe('EuiCollapsibleNavButton', () => { describe('right side', () => { it('renders a menu right icon when expanded', () => { const { container } = render( - + ); expect(container.firstChild).toMatchSnapshot(); @@ -51,7 +63,11 @@ describe('EuiCollapsibleNavButton', () => { it('renders a menu left icon when collapsed', () => { const { container } = render( - + ); expect(container.firstChild).toMatchSnapshot(); @@ -61,4 +77,36 @@ describe('EuiCollapsibleNavButton', () => { }); }); }); + + describe('mobile', () => { + it('renders an X icon when expanded', () => { + const { container } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + expect( + container.querySelector('[data-euiicon-type="cross"]') + ).toBeInTheDocument(); + }); + + it('renders a hamburger icon when collapsed', () => { + const { container } = render( + + ); + + expect(container.firstChild).toMatchSnapshot(); + expect( + container.querySelector('[data-euiicon-type="menu"]') + ).toBeInTheDocument(); + }); + }); }); diff --git a/src/components/collapsible_nav_beta/collapsible_nav_button/collapsible_nav_button.tsx b/src/components/collapsible_nav_beta/collapsible_nav_button/collapsible_nav_button.tsx index 0050347395b..f64ab5033de 100644 --- a/src/components/collapsible_nav_beta/collapsible_nav_button/collapsible_nav_button.tsx +++ b/src/components/collapsible_nav_beta/collapsible_nav_button/collapsible_nav_button.tsx @@ -19,23 +19,27 @@ import { euiCollapsibleNavButtonWrapperStyles } from './collapsible_nav_button.s export type EuiCollapsibleNavButtonProps = CommonProps & Partial & { isCollapsed: boolean; + isSmallScreen: boolean; side: EuiCollapsibleNavBetaProps['side']; }; export const EuiCollapsibleNavButton = forwardRef< HTMLDivElement, EuiCollapsibleNavButtonProps ->(({ isCollapsed, side, ...rest }, ref) => { +>(({ isCollapsed, isSmallScreen, side, ...rest }, ref) => { const euiTheme = useEuiTheme(); const styles = euiCollapsibleNavButtonWrapperStyles(euiTheme); const cssStyles = [styles.euiCollapsibleNavButtonWrapper, styles[side!]]; - // TODO: Mobile menu/cross behavior let iconType: string; - if (side === 'left') { - iconType = isCollapsed ? 'menuRight' : 'menuLeft'; + if (isSmallScreen) { + iconType = isCollapsed ? 'menu' : 'cross'; } else { - iconType = isCollapsed ? 'menuLeft' : 'menuRight'; + if (side === 'left') { + iconType = isCollapsed ? 'menuRight' : 'menuLeft'; + } else { + iconType = isCollapsed ? 'menuLeft' : 'menuRight'; + } } const toggleOpenLabel = useEuiI18n(