Browse Source

[frontend] add reusable component PushDrawer

- Adds new react component and tests
- Adds classnames library for conditionally joining classNames together
- Creates a hue5-globals.scss with styles as classes for common HTLM elements
based on Cloudera Design Language. For convinience over using sass variables.

[frontent] fix style lint issues

[frontend] remove semver caret for classnames package

[frontend] addressing pr feedback
Björn Alm 2 years ago
parent
commit
ba7ad20ba1

+ 69 - 0
desktop/core/src/desktop/js/components/styles/hue5-globals.scss

@@ -0,0 +1,69 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// WHEN TO USE:
+// Use in react.js based components to get the latest and correct
+// styles to your sass styling. This setup will be needed as long as we
+// are required to wrap the styles of antd-based react components
+// to prevent style leaking and mixing with the legacy bootstrap styles.
+
+@import './variables.scss';
+
+$heading-font-family: Roboto, 'Helvetica Neue', Helvetica, Arial, sans-serif;
+
+.antd {
+  .hue-h1 {
+    font-family: $heading-font-family;
+    font-size: $fluidx-heading-h1-size;
+    line-height: $fluidx-heading-h1-line-height;
+    font-weight: $fluidx-heading-h1-weight;
+  }
+
+  .hue-h2 {
+    font-family: $heading-font-family;
+    font-size: $fluidx-heading-h2-size;
+    line-height: $fluidx-heading-h2-line-height;
+    font-weight: $fluidx-heading-h2-weight;
+  }
+
+  .hue-h3 {
+    font-family: $heading-font-family;
+    font-size: $fluidx-heading-h3-size;
+    line-height: $fluidx-heading-h3-line-height;
+    font-weight: $fluidx-heading-h3-weight;
+  }
+
+  .hue-h4 {
+    font-family: $heading-font-family;
+    font-size: $fluidx-heading-h4-size;
+    line-height: $fluidx-heading-h4-line-height;
+    font-weight: $fluidx-heading-h4-weight;
+  }
+
+  .hue-h5 {
+    font-family: $heading-font-family;
+    font-size: $fluidx-heading-h5-size;
+    line-height: $fluidx-heading-h5-line-height;
+    font-weight: $fluidx-heading-h5-weight;
+  }
+
+  .hue-h6 {
+    font-family: $heading-font-family;
+    font-size: $fluidx-heading-h6-size;
+    line-height: $fluidx-heading-h6-line-height;
+    font-weight: $fluidx-heading-h6-weight;
+  }
+}

+ 28 - 0
desktop/core/src/desktop/js/components/styles/variables.scss

@@ -38,3 +38,31 @@ $fluid-spacing-xxl: 48px;
 $fluid-spacing-giant: 64px;
 
 $fluid-border-radius: 3px;
+
+$fluidx-heading-h1-size: 28px;
+$fluidx-heading-h1-line-height: 36px;
+$fluidx-heading-h1-weight: 300;
+$fluidx-heading-h2-size: 24px;
+$fluidx-heading-h2-line-height: 32px;
+$fluidx-heading-h2-weight: 300;
+$fluidx-heading-h3-size: 20px;
+$fluidx-heading-h3-line-height: 28px;
+$fluidx-heading-h3-weight: 300;
+$fluidx-heading-h4-size: 16px;
+$fluidx-heading-h4-line-height: 24px;
+$fluidx-heading-h4-weight: 500;
+$fluidx-heading-h5-size: 14px;
+$fluidx-heading-h5-line-height: 16px;
+$fluidx-heading-h5-weight: 500;
+$fluidx-heading-h6-size: 12px;
+$fluidx-heading-h6-line-height: 20px;
+$fluidx-heading-h6-weight: 500;
+
+/** For Modal Dialogs and Title Bars */
+$fluidx-title-h1-small-size: 20px;
+
+/** For Modal Dialogs and Title Bars */
+$fluidx-title-h1-small-line-height: 28px;
+
+/** For Modal Dialogs and Title Bars */
+$fluidx-title-h1-small-weight: 300;

+ 37 - 0
desktop/core/src/desktop/js/reactComponents/PushDrawer/Drawer/Drawer.scss

@@ -0,0 +1,37 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+@use '../../../components/styles/hue5-globals.scss';
+@import '../../../components/styles/variables';
+
+$border-color: $fluid-gray-300;
+
+.antd {
+  .hue-push-drawer__drawer {
+    position: absolute;
+    height: 100%;
+    transition: $animation-duration-base;
+    padding: $fluid-spacing-s;
+  }
+
+  .hue-push-drawer__drawer--left {
+    border-right: 1px solid $border-color;
+  }
+
+  .hue-push-drawer__drawer--right {
+    border-left: 1px solid $border-color;
+  }
+}

+ 87 - 0
desktop/core/src/desktop/js/reactComponents/PushDrawer/Drawer/Drawer.tsx

@@ -0,0 +1,87 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React, { FunctionComponent, useLayoutEffect, useState } from 'react';
+import classNames from 'classnames';
+
+import './Drawer.scss';
+
+export interface DrawerProps {
+  content: () => JSX.Element;
+  direction: 'left' | 'right';
+  header?: () => JSX.Element | null;
+  isOpen: boolean;
+  testId?: string;
+  width: string;
+}
+
+const defaultProps = {
+  testId: 'hue-push-drawer-drawer',
+  header: () => null
+};
+
+const Drawer: FunctionComponent<DrawerProps> = ({
+  content,
+  direction,
+  header,
+  isOpen,
+  testId,
+  width
+}) => {
+  const [preventFocus, setPreventFocus] = useState<boolean>(!isOpen);
+
+  useLayoutEffect(() => {
+    if (isOpen && preventFocus) {
+      setPreventFocus(false);
+    }
+  }, [isOpen]);
+
+  const leftPosition =
+    direction === 'left'
+      ? isOpen
+        ? '0px'
+        : `-${width}`
+      : `calc(100% - ${isOpen ? width : '0px'})`;
+
+  return (
+    <aside
+      data-testid={testId}
+      aria-hidden={!isOpen}
+      style={{ width, left: leftPosition }}
+      className={classNames('hue-push-drawer__drawer', `hue-push-drawer__drawer--${direction}`)}
+      onTransitionEnd={() => {
+        if (!isOpen) {
+          setPreventFocus(true);
+        }
+      }}
+    >
+      <div
+        data-testid={`${testId}-focus-preventer`}
+        style={{
+          // This prevents elements in the drawer to get focus when the
+          // drawer is closed, while allowing for a smooth css transition.
+          display: preventFocus ? 'none' : 'block'
+        }}
+      >
+        {header?.()}
+        {content()}
+      </div>
+    </aside>
+  );
+};
+
+Drawer.defaultProps = defaultProps;
+export default Drawer;

+ 40 - 0
desktop/core/src/desktop/js/reactComponents/PushDrawer/DrawerHeader/DrawerHeader.scss

@@ -0,0 +1,40 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+@use '../../../components/styles/hue5-globals.scss';
+@import '../../../components/styles/variables';
+
+.antd {
+  .hue-push-drawer-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-bottom: $fluid-spacing-s;
+  }
+
+  .hue-push-drawer-header__title {
+    margin: 0;
+  }
+
+  .hue-push-drawer-header__close-btn {
+    background-color: transparent;
+    border: none;
+    height: 24px;
+    width: 24px;
+    flex-shrink: 0;
+    padding: 0;
+  }
+}

+ 57 - 0
desktop/core/src/desktop/js/reactComponents/PushDrawer/DrawerHeader/DrawerHeader.tsx

@@ -0,0 +1,57 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React, { FunctionComponent } from 'react';
+
+import { i18nReact } from '../../../utils/i18nReact';
+import CloseIcon from '../../../components/icons/CloseIcon';
+
+import './DrawerHeader.scss';
+
+export interface DrawerHeaderProps {
+  closeBtnAriaLabel?: string;
+  onClose: () => void;
+  testId?: string;
+  title?: string;
+}
+
+const defaultProps = {
+  testId: 'hue-push-drawer-header'
+};
+
+const DrawerHeader: FunctionComponent<DrawerHeaderProps> = ({ onClose, testId, ...i18n }) => {
+  const { t } = i18nReact.useTranslation();
+  const { closeBtnAriaLabel = t('Close'), title } = i18n;
+
+  return (
+    <div className="hue-push-drawer-header" data-testid={`${testId}`}>
+      <h3 className="hue-h3 hue-push-drawer-header__title" data-testid={`${testId}-title`}>
+        {title}
+      </h3>
+      <button
+        data-testid={`${testId}-close-btn`}
+        aria-label={closeBtnAriaLabel}
+        className="hue-push-drawer-header__close-btn"
+        onClick={onClose}
+      >
+        <CloseIcon data-testid={`${testId}-close-icon`} />
+      </button>
+    </div>
+  );
+};
+
+DrawerHeader.defaultProps = defaultProps;
+export default DrawerHeader;

+ 38 - 0
desktop/core/src/desktop/js/reactComponents/PushDrawer/PushDrawer.scss

@@ -0,0 +1,38 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+@use '../../components/styles/hue5-globals.scss';
+@import '../../components/styles/variables';
+
+$spacing: $fluid-spacing-s;
+
+// The .antd wrapper is needed to bind closer than the reset-globals.less
+// which resets stuff like borders etc needed for antd components.
+.antd {
+  .hue-push-drawer {
+    position: relative;
+    overflow: hidden;
+    height: inherit;
+  }
+
+  .hue-push-drawer__content {
+    position: absolute;
+    transition: $animation-duration-base;
+    height: 100%;
+    width: 100%;
+    padding: $spacing;
+  }
+}

+ 719 - 0
desktop/core/src/desktop/js/reactComponents/PushDrawer/PushDrawer.test.tsx

@@ -0,0 +1,719 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React from 'react';
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import '@testing-library/jest-dom';
+
+import PushDrawer, { DrawerHeader } from './PushDrawer';
+
+const drawerTestTitle = 'test drawer title';
+const drawerContentCheckboxLabel = 'test content checkbox';
+
+const getTestDrawerConfig = () => ({
+  width: '100px',
+  header: () => <DrawerHeader onClose={jest.fn()} title={drawerTestTitle} />,
+  content: () => (
+    <div>
+      <input type="checkbox" id="chk1-label" />
+      <label htmlFor="chk1-label">{drawerContentCheckboxLabel}</label>
+    </div>
+  )
+});
+
+describe('PushDrawer', () => {
+  describe('with left drawer open', () => {
+    test('renders drawer on the left side in parent container', () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      render(<PushDrawer leftDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      const drawerContainer = screen.getByTestId('hue-push-drawer-left-drawer');
+      expect(getComputedStyle(drawerContainer).width).toEqual('100px');
+      expect(getComputedStyle(drawerContainer).left).toEqual('0px');
+    });
+
+    test('sets the correct width on content container', () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      render(<PushDrawer leftDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      const contentContainer = screen.getByTestId('hue-push-drawer-content');
+      expect(getComputedStyle(contentContainer).width).toEqual('calc(100% - 100px)');
+    });
+
+    test('renders DrawerHeader as visible', () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      render(<PushDrawer leftDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      const drawerContainer = screen.getByTestId('hue-push-drawer-left-drawer');
+      expect(within(drawerContainer).getByRole('heading', { level: 3 })).toBeVisible();
+      expect(within(drawerContainer).getByRole('button', { name: 'Close' })).toBeVisible();
+    });
+
+    test('renders drawer with custom title in DrawerHeader', async () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      render(<PushDrawer leftDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      const drawerContainer = screen.getByTestId('hue-push-drawer-left-drawer');
+      expect(within(drawerContainer).getByText(drawerTestTitle)).toBeVisible();
+    });
+
+    test('renders custom JSX header', () => {
+      const myTestConfig = {
+        ...getTestDrawerConfig(),
+        isOpen: true,
+        header: () => <h1>custom-header</h1>
+      };
+      render(<PushDrawer leftDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      const drawerContainer = screen.getByTestId('hue-push-drawer-left-drawer');
+      expect(within(drawerContainer).getByText('custom-header')).toBeVisible();
+    });
+
+    test('renders drawer content as visible', () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      render(<PushDrawer leftDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      const drawerContainer = screen.getByTestId('hue-push-drawer-left-drawer');
+      const checkbox = within(drawerContainer).getByRole('checkbox', {
+        name: drawerContentCheckboxLabel
+      });
+      expect(checkbox).toBeVisible();
+    });
+
+    test('accepts tab focus on input elements placed in the drawer', async () => {
+      const user = userEvent.setup();
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      render(<PushDrawer leftDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      expect(document.body).toHaveFocus();
+
+      const drawerContainer = screen.getByTestId('hue-push-drawer-left-drawer');
+      const closeButton = within(drawerContainer).getByRole('button', { name: 'Close' });
+      const contentCheckbox = within(drawerContainer).getByRole('checkbox', {
+        name: drawerContentCheckboxLabel
+      });
+
+      await user.tab();
+      expect(closeButton).toHaveFocus();
+      await user.tab();
+      expect(contentCheckbox).toHaveFocus();
+    });
+
+    test('has visible main content', async () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      render(
+        <PushDrawer
+          leftDrawer={myTestConfig}
+          mainContent={() => (
+            <div>
+              <button>main content button</button>
+            </div>
+          )}
+        />
+      );
+      const mainContent = screen.getByTestId('hue-push-drawer-content');
+      expect(
+        within(mainContent).getByRole('button', { name: 'main content button' })
+      ).toBeVisible();
+    });
+
+    test('has focusable main content', async () => {
+      const user = userEvent.setup();
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      const configWithEmptyDrawer = {
+        ...myTestConfig,
+        header: () => <></>,
+        content: () => <></>
+      };
+      render(
+        <PushDrawer
+          leftDrawer={configWithEmptyDrawer}
+          mainContent={() => (
+            <div>
+              <button>test me</button>
+            </div>
+          )}
+        />
+      );
+      const mainContent = screen.getByTestId('hue-push-drawer-content');
+
+      await user.tab();
+      expect(within(mainContent).getByRole('button', { name: 'test me' })).toHaveFocus();
+    });
+
+    test('calls onClose when close button is clicked', async () => {
+      const onCloseMock = jest.fn();
+      const user = userEvent.setup();
+      const myTestConfig = {
+        ...getTestDrawerConfig(),
+        isOpen: true,
+        header: () => <DrawerHeader onClose={onCloseMock} />
+      };
+
+      render(<PushDrawer leftDrawer={myTestConfig} mainContent={() => <div></div>} />);
+      const drawerContainer = screen.getByTestId('hue-push-drawer-left-drawer');
+      const closeButton = within(drawerContainer).getByRole('button', { name: 'Close' });
+
+      expect(onCloseMock).not.toHaveBeenCalled();
+      await user.click(closeButton);
+      expect(onCloseMock).toHaveBeenCalled();
+    });
+  });
+
+  describe('with left drawer closed', () => {
+    test('renders drawer outside parent container', () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: false };
+      render(<PushDrawer leftDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      const drawerContainer = screen.getByTestId('hue-push-drawer-left-drawer');
+      const drawerLeftPosition = getComputedStyle(drawerContainer).left;
+
+      expect(getComputedStyle(drawerContainer).width).toEqual('100px');
+      expect(drawerLeftPosition).toEqual('-100px');
+      expect(
+        within(drawerContainer).getByRole('heading', { level: 3, hidden: true })
+      ).not.toBeVisible();
+      expect(
+        within(drawerContainer).getByRole('button', { name: 'Close', hidden: true })
+      ).not.toBeVisible();
+    });
+
+    test('sets the correct width on main content container', () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      render(<PushDrawer leftDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      const contentContainer = screen.getByTestId('hue-push-drawer-content');
+      expect(getComputedStyle(contentContainer).width).toEqual('calc(100% - 100px)');
+    });
+
+    test('renders DrawerHeader as not visible', () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: false };
+      render(<PushDrawer leftDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      const drawerContainer = screen.getByTestId('hue-push-drawer-left-drawer');
+      expect(
+        within(drawerContainer).getByRole('heading', { level: 3, hidden: true })
+      ).not.toBeVisible();
+      expect(
+        within(drawerContainer).getByRole('button', { name: 'Close', hidden: true })
+      ).not.toBeVisible();
+    });
+
+    test('does not accept tab focus on input elements placed in the drawer', async () => {
+      const user = userEvent.setup();
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: false };
+      render(<PushDrawer leftDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      const drawerContainer = screen.getByTestId('hue-push-drawer-left-drawer');
+      const closeButton = within(drawerContainer).getByRole('button', {
+        name: 'Close',
+        hidden: true
+      });
+      const contentCheckbox = within(drawerContainer).getByRole('checkbox', {
+        name: drawerContentCheckboxLabel,
+        hidden: true
+      });
+
+      expect(document.body).toHaveFocus();
+      await user.tab();
+      expect(closeButton).not.toHaveFocus();
+      expect(contentCheckbox).not.toHaveFocus();
+      await user.tab();
+      expect(closeButton).not.toHaveFocus();
+      expect(contentCheckbox).not.toHaveFocus();
+    });
+
+    test('has visible main content', async () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: false };
+      render(
+        <PushDrawer
+          leftDrawer={myTestConfig}
+          mainContent={() => (
+            <div>
+              <button>main content button</button>
+            </div>
+          )}
+        />
+      );
+      const mainContent = screen.getByTestId('hue-push-drawer-content');
+      expect(
+        within(mainContent).getByRole('button', { name: 'main content button' })
+      ).toBeVisible();
+    });
+
+    test('has focusable main content', async () => {
+      const user = userEvent.setup();
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: false };
+      const configWithEmptyDrawer = {
+        ...myTestConfig,
+        header: () => <></>,
+        content: () => <></>
+      };
+      render(
+        <PushDrawer
+          leftDrawer={configWithEmptyDrawer}
+          mainContent={() => (
+            <div>
+              <button>test me</button>
+            </div>
+          )}
+        />
+      );
+      const mainContent = screen.getByTestId('hue-push-drawer-content');
+      await user.tab();
+      expect(within(mainContent).getByRole('button', { name: 'test me' })).toHaveFocus();
+    });
+  });
+
+  describe('with right drawer open', () => {
+    test('renders drawer on the left side in parent container', () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      render(<PushDrawer rightDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      const drawerContainer = screen.getByTestId('hue-push-drawer-right-drawer');
+      expect(getComputedStyle(drawerContainer).width).toEqual('100px');
+      expect(getComputedStyle(drawerContainer).left).toEqual('calc(100% - 100px)');
+    });
+
+    test('sets the correct width on main content container', () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      render(<PushDrawer rightDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      const contentContainer = screen.getByTestId('hue-push-drawer-content');
+      expect(getComputedStyle(contentContainer).width).toEqual('calc(100% - 100px)');
+    });
+
+    test('renders DrawerHeader as visible', () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      render(<PushDrawer rightDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      const drawerContainer = screen.getByTestId('hue-push-drawer-right-drawer');
+      expect(within(drawerContainer).getByRole('heading', { level: 3 })).toBeVisible();
+      expect(within(drawerContainer).getByRole('button', { name: 'Close' })).toBeVisible();
+    });
+
+    test('renders drawer with custom title in DrawerHeader', async () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      render(<PushDrawer rightDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      const drawerContainer = screen.getByTestId('hue-push-drawer-right-drawer');
+      expect(within(drawerContainer).getByText(drawerTestTitle)).toBeVisible();
+    });
+
+    test('renders custom JSX header', () => {
+      const myTestConfig = {
+        ...getTestDrawerConfig(),
+        isOpen: true,
+        header: () => <h1>custom-header</h1>
+      };
+      render(<PushDrawer rightDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      const drawerContainer = screen.getByTestId('hue-push-drawer-right-drawer');
+      expect(within(drawerContainer).getByText('custom-header')).toBeVisible();
+    });
+
+    test('renders drawer content as visible', () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      render(<PushDrawer rightDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      const drawerContainer = screen.getByTestId('hue-push-drawer-right-drawer');
+      const checkbox = within(drawerContainer).getByRole('checkbox', {
+        name: drawerContentCheckboxLabel
+      });
+      expect(checkbox).toBeVisible();
+    });
+
+    test('accepts tab focus on input elements placed in the drawer', async () => {
+      const user = userEvent.setup();
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      render(<PushDrawer rightDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      expect(document.body).toHaveFocus();
+
+      const drawerContainer = screen.getByTestId('hue-push-drawer-right-drawer');
+      const closeButton = within(drawerContainer).getByRole('button', { name: 'Close' });
+      const contentCheckbox = within(drawerContainer).getByRole('checkbox', {
+        name: drawerContentCheckboxLabel
+      });
+
+      await user.tab();
+      expect(closeButton).toHaveFocus();
+      await user.tab();
+      expect(contentCheckbox).toHaveFocus();
+    });
+
+    test('has visible main content', async () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      render(
+        <PushDrawer
+          rightDrawer={myTestConfig}
+          mainContent={() => (
+            <div>
+              <button>main content button</button>
+            </div>
+          )}
+        />
+      );
+      const mainContent = screen.getByTestId('hue-push-drawer-content');
+      expect(
+        within(mainContent).getByRole('button', { name: 'main content button' })
+      ).toBeVisible();
+    });
+
+    test('has focusable main content', async () => {
+      const user = userEvent.setup();
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      const configWithEmptyDrawer = {
+        ...myTestConfig,
+        header: () => <></>,
+        content: () => <></>
+      };
+      render(
+        <PushDrawer
+          rightDrawer={configWithEmptyDrawer}
+          mainContent={() => (
+            <div>
+              <button>test me</button>
+            </div>
+          )}
+        />
+      );
+      const mainContent = screen.getByTestId('hue-push-drawer-content');
+
+      await user.tab();
+      expect(within(mainContent).getByRole('button', { name: 'test me' })).toHaveFocus();
+    });
+
+    test('calls onClose when close button is clicked', async () => {
+      const onCloseMock = jest.fn();
+      const user = userEvent.setup();
+      const myTestConfig = {
+        ...getTestDrawerConfig(),
+        isOpen: true,
+        header: () => <DrawerHeader onClose={onCloseMock} />
+      };
+
+      render(<PushDrawer rightDrawer={myTestConfig} mainContent={() => <div></div>} />);
+      const drawerContainer = screen.getByTestId('hue-push-drawer-right-drawer');
+      const closeButton = within(drawerContainer).getByRole('button', { name: 'Close' });
+
+      expect(onCloseMock).not.toHaveBeenCalled();
+      await user.click(closeButton);
+      expect(onCloseMock).toHaveBeenCalled();
+    });
+  });
+
+  describe('with right drawer closed', () => {
+    test('renders drawer outside parent container', () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: false };
+      render(<PushDrawer rightDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      const drawerContainer = screen.getByTestId('hue-push-drawer-right-drawer');
+      expect(getComputedStyle(drawerContainer).width).toEqual('100px');
+      expect(getComputedStyle(drawerContainer).left).toEqual('calc(100% - 0px)');
+    });
+
+    test('sets the correct width on main content container', () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      render(<PushDrawer rightDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      const contentContainer = screen.getByTestId('hue-push-drawer-content');
+      expect(getComputedStyle(contentContainer).width).toEqual('calc(100% - 100px)');
+    });
+
+    test('renders DrawerHeader as not visible', () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: false };
+      render(<PushDrawer rightDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      const drawerContainer = screen.getByTestId('hue-push-drawer-right-drawer');
+      expect(
+        within(drawerContainer).getByRole('heading', { level: 3, hidden: true })
+      ).not.toBeVisible();
+      expect(
+        within(drawerContainer).getByRole('button', { name: 'Close', hidden: true })
+      ).not.toBeVisible();
+    });
+
+    test('does not accept tab focus on input elements placed in the drawer', async () => {
+      const user = userEvent.setup();
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: false };
+      render(<PushDrawer rightDrawer={myTestConfig} mainContent={() => <div></div>} />);
+
+      const drawerContainer = screen.getByTestId('hue-push-drawer-right-drawer');
+      const closeButton = within(drawerContainer).getByRole('button', {
+        name: 'Close',
+        hidden: true
+      });
+      const contentCheckbox = within(drawerContainer).getByRole('checkbox', {
+        name: drawerContentCheckboxLabel,
+        hidden: true
+      });
+
+      expect(document.body).toHaveFocus();
+      await user.tab();
+      expect(closeButton).not.toHaveFocus();
+      expect(contentCheckbox).not.toHaveFocus();
+      await user.tab();
+      expect(closeButton).not.toHaveFocus();
+      expect(contentCheckbox).not.toHaveFocus();
+    });
+
+    test('has visible main content', async () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: false };
+      render(
+        <PushDrawer
+          rightDrawer={myTestConfig}
+          mainContent={() => (
+            <div>
+              <button>main content button</button>
+            </div>
+          )}
+        />
+      );
+      const mainContent = screen.getByTestId('hue-push-drawer-content');
+      expect(
+        within(mainContent).getByRole('button', { name: 'main content button' })
+      ).toBeVisible();
+    });
+
+    test('has focusable main content', async () => {
+      const user = userEvent.setup();
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: false };
+      const configWithEmptyDrawer = {
+        ...myTestConfig,
+        header: () => <></>,
+        content: () => <></>
+      };
+      render(
+        <PushDrawer
+          rightDrawer={configWithEmptyDrawer}
+          mainContent={() => (
+            <div>
+              <button>test me</button>
+            </div>
+          )}
+        />
+      );
+      const mainContent = screen.getByTestId('hue-push-drawer-content');
+      await user.tab();
+      expect(within(mainContent).getByRole('button', { name: 'test me' })).toHaveFocus();
+    });
+  });
+
+  describe('with left AND right drawer open', () => {
+    test('renders drawers on the left and right side in parent container', () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      render(
+        <PushDrawer
+          leftDrawer={myTestConfig}
+          rightDrawer={myTestConfig}
+          mainContent={() => <div></div>}
+        />
+      );
+
+      const leftDrawerContainer = screen.getByTestId('hue-push-drawer-left-drawer');
+      expect(getComputedStyle(leftDrawerContainer).width).toEqual('100px');
+      expect(getComputedStyle(leftDrawerContainer).left).toEqual('0px');
+
+      const rightDrawerContainer = screen.getByTestId('hue-push-drawer-right-drawer');
+      expect(getComputedStyle(rightDrawerContainer).width).toEqual('100px');
+      expect(getComputedStyle(rightDrawerContainer).left).toEqual('calc(100% - 100px)');
+    });
+
+    test('sets the correct width on content container', () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      render(
+        <PushDrawer
+          leftDrawer={myTestConfig}
+          rightDrawer={myTestConfig}
+          mainContent={() => <div></div>}
+        />
+      );
+
+      const contentContainer = screen.getByTestId('hue-push-drawer-content');
+      expect(getComputedStyle(contentContainer).width).toEqual('calc(100% - 200px)');
+    });
+
+    test('renders DrawerHeaders as visible', () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      render(
+        <PushDrawer
+          leftDrawer={myTestConfig}
+          rightDrawer={myTestConfig}
+          mainContent={() => <div></div>}
+        />
+      );
+
+      const leftDrawerContainer = screen.getByTestId('hue-push-drawer-left-drawer');
+      expect(within(leftDrawerContainer).getByRole('heading', { level: 3 })).toBeVisible();
+      expect(within(leftDrawerContainer).getByRole('button', { name: 'Close' })).toBeVisible();
+
+      const rightDrawerContainer = screen.getByTestId('hue-push-drawer-right-drawer');
+      expect(within(rightDrawerContainer).getByRole('heading', { level: 3 })).toBeVisible();
+      expect(within(rightDrawerContainer).getByRole('button', { name: 'Close' })).toBeVisible();
+    });
+
+    test('renders all drawer content as visible', () => {
+      const myLeftTestConfig = {
+        ...getTestDrawerConfig(),
+        isOpen: true,
+        content: () => (
+          <div>
+            <input type="checkbox" id="chk1-label" />
+            <label htmlFor="chk1-label">{drawerContentCheckboxLabel}</label>
+          </div>
+        )
+      };
+      const myRightTestConfig = {
+        ...getTestDrawerConfig(),
+        isOpen: true,
+        content: () => (
+          <div>
+            <input type="checkbox" id="chk2-label" />
+            <label htmlFor="chk2-label">{drawerContentCheckboxLabel}</label>
+          </div>
+        )
+      };
+
+      render(
+        <PushDrawer
+          leftDrawer={myLeftTestConfig}
+          rightDrawer={myRightTestConfig}
+          mainContent={() => <div></div>}
+        />
+      );
+
+      const leftDrawerContainer = screen.getByTestId('hue-push-drawer-left-drawer');
+      const leftDrawerCheckbox = within(leftDrawerContainer).getByRole('checkbox', {
+        name: drawerContentCheckboxLabel
+      });
+      expect(leftDrawerCheckbox).toBeVisible();
+
+      const rightDrawerContainer = screen.getByTestId('hue-push-drawer-right-drawer');
+      const rightDrawerCheckbox = within(rightDrawerContainer).getByRole('checkbox', {
+        name: drawerContentCheckboxLabel
+      });
+      expect(rightDrawerCheckbox).toBeVisible();
+    });
+
+    test('accepts tab focus on input elements placed in both the drawers', async () => {
+      const user = userEvent.setup();
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true, content: () => <div></div> };
+      render(
+        <PushDrawer
+          leftDrawer={myTestConfig}
+          rightDrawer={myTestConfig}
+          mainContent={() => <div></div>}
+        />
+      );
+
+      expect(document.body).toHaveFocus();
+
+      const leftDrawerContainer = screen.getByTestId('hue-push-drawer-left-drawer');
+      const leftCloseButton = within(leftDrawerContainer).getByRole('button', { name: 'Close' });
+      const rightDrawerContainer = screen.getByTestId('hue-push-drawer-right-drawer');
+      const rightCloseButton = within(rightDrawerContainer).getByRole('button', { name: 'Close' });
+
+      await user.tab();
+      expect(leftCloseButton).toHaveFocus();
+      await user.tab();
+      expect(rightCloseButton).toHaveFocus();
+    });
+
+    test('has visible main content', async () => {
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      render(
+        <PushDrawer
+          leftDrawer={myTestConfig}
+          rightDrawer={myTestConfig}
+          mainContent={() => (
+            <div>
+              <button>main content button</button>
+            </div>
+          )}
+        />
+      );
+      const mainContent = screen.getByTestId('hue-push-drawer-content');
+      expect(
+        within(mainContent).getByRole('button', { name: 'main content button' })
+      ).toBeVisible();
+    });
+
+    test('has focusable main content', async () => {
+      const user = userEvent.setup();
+      const myTestConfig = { ...getTestDrawerConfig(), isOpen: true };
+      const configWithEmptyDrawer = {
+        ...myTestConfig,
+        header: () => <></>,
+        content: () => <></>
+      };
+      render(
+        <PushDrawer
+          leftDrawer={configWithEmptyDrawer}
+          rightDrawer={configWithEmptyDrawer}
+          mainContent={() => (
+            <div>
+              <button>test me</button>
+            </div>
+          )}
+        />
+      );
+      const mainContent = screen.getByTestId('hue-push-drawer-content');
+
+      await user.tab();
+      expect(within(mainContent).getByRole('button', { name: 'test me' })).toHaveFocus();
+    });
+
+    test('calls correct onClose when close buttons are clicked', async () => {
+      const onLeftCloseMock = jest.fn();
+      const onRightCloseMock = jest.fn();
+      const user = userEvent.setup();
+      const leftTestConfig = {
+        ...getTestDrawerConfig(),
+        isOpen: true,
+        header: () => <DrawerHeader onClose={onLeftCloseMock} />
+      };
+      const rightTestConfig = {
+        ...getTestDrawerConfig(),
+        isOpen: true,
+        header: () => <DrawerHeader onClose={onRightCloseMock} />
+      };
+
+      render(
+        <PushDrawer
+          leftDrawer={leftTestConfig}
+          rightDrawer={rightTestConfig}
+          mainContent={() => <div></div>}
+        />
+      );
+
+      const leftDrawerContainer = screen.getByTestId('hue-push-drawer-left-drawer');
+      const leftCloseButton = within(leftDrawerContainer).getByRole('button', { name: 'Close' });
+
+      expect(onLeftCloseMock).not.toHaveBeenCalled();
+      await user.click(leftCloseButton);
+      expect(onLeftCloseMock).toHaveBeenCalled();
+
+      const rightDrawerContainer = screen.getByTestId('hue-push-drawer-right-drawer');
+      const rightCloseButton = within(rightDrawerContainer).getByRole('button', { name: 'Close' });
+
+      expect(onRightCloseMock).not.toHaveBeenCalled();
+      await user.click(rightCloseButton);
+      expect(onRightCloseMock).toHaveBeenCalled();
+    });
+  });
+});

+ 117 - 0
desktop/core/src/desktop/js/reactComponents/PushDrawer/PushDrawer.tsx

@@ -0,0 +1,117 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React, { FunctionComponent } from 'react';
+import classNames from 'classnames';
+
+import Drawer from './Drawer/Drawer';
+import DrawerHeaderExport from './DrawerHeader/DrawerHeader';
+
+import './PushDrawer.scss';
+
+/**
+ * PushDrawer - stateless drawer that pushes main content to the side when open.
+ * Uses render props for drawer header, drawer content and main content.
+ * Supports smoth animations for drawers on the left and right side that can
+ * be used simultaneously.
+ *
+ * Example usage:
+ *
+ * import PushDrawer, { DrawerHeader} from './PushDrawer/PushDrawer';
+ * const [isOpen, setIsOpen] = useState(true);
+ *
+ * <PushDrawer
+ *  leftDrawer={
+ *    {
+ *      content: () => <div> I'm the left drawer content </div>
+ *      header: () => <DrawerHeader onClose={() => setIsOpen(false)} title={'My drawer'} />,
+ *      isOpen,
+ *      width: '100px',
+ *    }
+ *  }
+ *  mainContent={() => <div>I'm the main content</div>}
+ * />
+ */
+export const DrawerHeader = DrawerHeaderExport;
+export interface DrawerConfig {
+  isOpen: boolean;
+  width: string;
+  header?: () => JSX.Element | null;
+  content: () => JSX.Element;
+}
+
+export interface PushDrawerProps {
+  mainContent: () => JSX.Element;
+  testId?: string;
+  leftDrawer?: DrawerConfig;
+  rightDrawer?: DrawerConfig;
+}
+
+const defaultProps = {
+  testId: 'hue-push-drawer'
+};
+
+const PushDrawer: FunctionComponent<PushDrawerProps> = ({
+  mainContent,
+  testId,
+  leftDrawer,
+  rightDrawer
+}) => {
+  const spaceUsedByLeftDrawer = leftDrawer?.isOpen ? parseInt(leftDrawer.width) : 0;
+  const spaceUsedByRightDrawer = rightDrawer?.isOpen ? parseInt(rightDrawer.width) : 0;
+  const totalSpaceUsedByDrawers = spaceUsedByLeftDrawer + spaceUsedByRightDrawer;
+
+  const mainContentWidth = `calc(100% - ${totalSpaceUsedByDrawers}px)`;
+  const mainContentLeftPosition = leftDrawer?.isOpen ? leftDrawer.width : 0;
+
+  return (
+    <React.StrictMode>
+      <div className="hue-push-drawer" data-testid={testId}>
+        {leftDrawer && (
+          <Drawer
+            isOpen={leftDrawer.isOpen}
+            width={leftDrawer.width}
+            header={leftDrawer.header}
+            content={leftDrawer.content}
+            direction="left"
+            testId={`${testId}-left-drawer`}
+          />
+        )}
+        <div
+          data-testid={`${testId}-content`}
+          style={{ width: mainContentWidth, left: mainContentLeftPosition }}
+          className={classNames('hue-push-drawer__content')}
+        >
+          {mainContent()}
+        </div>
+
+        {rightDrawer && (
+          <Drawer
+            isOpen={rightDrawer.isOpen}
+            width={rightDrawer.width}
+            header={rightDrawer.header}
+            content={rightDrawer.content}
+            direction="right"
+            testId={`${testId}-right-drawer`}
+          />
+        )}
+      </div>
+    </React.StrictMode>
+  );
+};
+
+PushDrawer.defaultProps = defaultProps;
+export default PushDrawer;

+ 1 - 0
package-lock.json

@@ -14,6 +14,7 @@
         "antd": "4.23.1",
         "axios": "0.24.0",
         "babel-preset-react-app": "3.1.2",
+        "classnames": "2.3.2",
         "clipboard": "1.7.1",
         "core-js": "3.19.1",
         "d3v3": "1.0.3",

+ 1 - 0
package.json

@@ -35,6 +35,7 @@
     "antd": "4.23.1",
     "axios": "0.24.0",
     "babel-preset-react-app": "3.1.2",
+    "classnames": "2.3.2",
     "clipboard": "1.7.1",
     "core-js": "3.19.1",
     "d3v3": "1.0.3",