Skip to content

Commit

Permalink
Implement mobile/responsive logic
Browse files Browse the repository at this point in the history
- 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)
  • Loading branch information
cee-chen committed Aug 2, 2023
1 parent 85b96cb commit 0a1b43d
Show file tree
Hide file tree
Showing 7 changed files with 388 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,156 @@ exports[`EuiCollapsibleNavBeta renders 1`] = `
</div>
</body>
`;

exports[`EuiCollapsibleNavBeta renders initialIsCollapsed 1`] = `
<body
class="euiBody--hasFlyout"
style="padding-left: 0px;"
>
<div>
<div
class="euiCollapsibleNavButtonWrapper emotion-euiCollapsibleNavButtonWrapper-left"
>
<button
aria-controls="generated-id_euiCollapsibleNav"
aria-expanded="false"
aria-label="Toggle navigation open"
aria-pressed="false"
class="euiButtonIcon euiCollapsibleNavButton emotion-euiButtonIcon-s-empty-text"
data-test-subj="euiCollapsibleNavButton"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="menuRight"
/>
</button>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="-1"
/>
<div
data-focus-lock-disabled="disabled"
>
<nav
class="euiFlyout euiCollapsibleNav euiCollapsibleNavBeta emotion-euiFlyout-none-noMaxWidth-push-left-left-euiCollapsibleNavBeta-left"
data-autofocus="true"
data-test-subj="nav"
id="generated-id_euiCollapsibleNav"
role="dialog"
style="inline-size: 48px;"
tabindex="0"
>
Nav content
</nav>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="-1"
/>
</div>
</body>
`;

exports[`EuiCollapsibleNavBeta responsive behavior collapses from a push flyout to an overlay flyout once the screen is smaller than 3x the flyout width 1`] = `
<body
class=""
style="padding-left: 0px;"
>
<div>
<div
class="euiCollapsibleNavButtonWrapper emotion-euiCollapsibleNavButtonWrapper-left"
>
<button
aria-controls="generated-id_euiCollapsibleNav"
aria-expanded="false"
aria-label="Toggle navigation open"
aria-pressed="false"
class="euiButtonIcon euiCollapsibleNavButton emotion-euiButtonIcon-s-empty-text"
data-test-subj="euiCollapsibleNavButton"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="menu"
/>
</button>
</div>
</div>
</body>
`;

exports[`EuiCollapsibleNavBeta responsive behavior makes the overlay flyout full width once the screen is smaller than 1.5x the flyout width 1`] = `
<body
class="euiBody--hasFlyout"
style="padding-left: 0px;"
>
<div>
<div
class="euiCollapsibleNavButtonWrapper emotion-euiCollapsibleNavButtonWrapper-left"
>
<button
aria-controls="generated-id_euiCollapsibleNav"
aria-expanded="true"
aria-label="Toggle navigation closed"
aria-pressed="true"
class="euiButtonIcon euiCollapsibleNavButton emotion-euiButtonIcon-s-empty-text"
data-test-subj="euiCollapsibleNavButton"
type="button"
>
<span
aria-hidden="true"
class="euiButtonIcon__icon"
color="inherit"
data-euiicon-type="cross"
/>
</button>
</div>
</div>
<div
class="euiOverlayMask emotion-euiOverlayMask-belowHeader"
data-euiportal="true"
data-relative-to-header="below"
>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
data-focus-lock-disabled="false"
>
<nav
aria-describedby="generated-id"
class="euiFlyout euiCollapsibleNav euiCollapsibleNavBeta emotion-euiFlyout-none-noMaxWidth-overlay-left-euiCollapsibleNavBeta-left-isSmallestScreen"
data-autofocus="true"
id="generated-id_euiCollapsibleNav"
role="dialog"
style="inline-size: 100%;"
tabindex="0"
>
<p
class="emotion-euiScreenReaderOnly"
id="generated-id"
>
You are in a modal dialog. Press Escape or tap/click outside the dialog on the shadowed overlay to close.
</p>
Nav content
</nav>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</div>
</body>
`;
Original file line number Diff line number Diff line change
Expand Up @@ -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')}
}
`,
};
};
66 changes: 61 additions & 5 deletions src/components/collapsible_nav_beta/collapsible_nav_beta.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -29,23 +30,24 @@ describe('EuiCollapsibleNavBeta', () => {
});

it('renders initialIsCollapsed', () => {
const { queryByTestSubject } = render(
const { baseElement, getByTestSubject } = render(
<EuiCollapsibleNavBeta data-test-subj="nav" initialIsCollapsed={true}>
Nav content
</EuiCollapsibleNavBeta>
);
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(
<EuiCollapsibleNavBeta data-test-subj="nav">
Nav content
</EuiCollapsibleNavBeta>
);
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', () => {
Expand All @@ -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(
<EuiCollapsibleNavBeta>Nav content</EuiCollapsibleNavBeta>
);
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(
<EuiCollapsibleNavBeta>Nav content</EuiCollapsibleNavBeta>
);
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(
<EuiCollapsibleNavBeta width={500} data-test-subj="pushFlyout">
Nav content
</EuiCollapsibleNavBeta>
);
expect(desktop.getByTestSubject('pushFlyout')).toBeInTheDocument();
desktop.unmount();

mockWindowResize(1200);
const mobile = render(
<EuiCollapsibleNavBeta width={500} data-test-subj="overlayFlyout">
Nav content
</EuiCollapsibleNavBeta>
);
expect(
mobile.queryByTestSubject('overlayFlyout')
).not.toBeInTheDocument();
});
});

// TODO: Visual snapshot for left vs right `side` prop, once we add visual snapshot testing
});
69 changes: 63 additions & 6 deletions src/components/collapsible_nav_beta/collapsible_nav_beta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<
Expand All @@ -53,6 +68,7 @@ export const EuiCollapsibleNavBeta: FunctionComponent<
className,
style,
initialIsCollapsed = false,
width: _width = 248,
side = 'left',
focusTrapProps: _focusTrapProps,
...rest
Expand All @@ -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
*/
Expand Down Expand Up @@ -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 && (
Expand All @@ -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}
>
Expand All @@ -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}
</>
);
};
Loading

0 comments on commit 0a1b43d

Please sign in to comment.