Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LG-4830: add DrawerTabs component #2720

Merged
merged 12 commits into from
Feb 14, 2025
5 changes: 5 additions & 0 deletions .changeset/perfect-badgers-relate.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
'@leafygreen-ui/drawer': major
---

#### Drawer
Stub for Drawer changeset

#### DrawerTabs
- Adds `DrawerTabs` component with fixed `size`, alignment, and scrolling behavior to be used when implementing a `Tabs` instance as a direct child of a `Drawer` instance. `Tabs` from `@leafygreen-ui/tabs` can be used as children of `DrawerTabs`

#### Test Harnesses
- Exports `getTestUtils`, a util to reliably interact with LG `Drawer` component instances in a product test suite. For more details, check out the [README](https://github.com/mongodb/leafygreen-ui/tree/main/packages/drawer#test-harnesses)
- Exports the constant, `LGIDS_DRAWER`, which stores `data-lgid` values.
18 changes: 16 additions & 2 deletions packages/drawer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ npm install @leafygreen-ui/drawer

```tsx
import React, { useState } from 'react';
import { Drawer } from '@leafygreen-ui/drawer';

import Button from '@leafygreen-ui/button';
import { Drawer, DrawerTabs } from '@leafygreen-ui/drawer';
import { Tab } from '@leafygreen-ui/tabs';

function ExampleComponent() {
const [open, setOpen] = useState(false);
Expand All @@ -40,7 +42,11 @@ function ExampleComponent() {
Open Drawer
</Button>
<Drawer open={open} setOpen={setOpen} title="Drawer Title">
Drawer Content goes here.
<DrawerTabs>
<Tab name="Tab 1">Tab 1 content</Tab>
<Tab name="Tab 2">Tab 2 content</Tab>
<Tab name="Tab 3">Tab 3 content</Tab>
</DrawerTabs>
</Drawer>
</>
);
Expand All @@ -49,13 +55,21 @@ function ExampleComponent() {

## Properties

### Drawer

| Prop | Type | Description | Default |
| ----------------------- | ----------- | -------------------------------------------------- | ------- |
| `children` _(optional)_ | `ReactNode` | Children that will be rendered inside the `Drawer` | |
| `open` | `boolean` | Determines if the `Drawer` is open or closed | `false` |
| `setOpen` | `function` | Callback to change the open state of the `Drawer` | |
| `title` | `ReactNode` | Title of the `Drawer` | |

### DrawerTabs

Refer to the [props table in @leafygreen-ui/tabs README.md](https://github.com/mongodb/leafygreen-ui/blob/main/packages/tabs/README.md#properties) for a full list of props that can be passed to `DrawerTabs` instances.

`size` prop is fixed to ensure proper UI within the `Drawer`.

# Test Harnesses

## getTestUtils()
Expand Down
6 changes: 5 additions & 1 deletion packages/drawer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,17 @@
"@leafygreen-ui/icon": "workspace:^",
"@leafygreen-ui/icon-button": "workspace:^",
"@leafygreen-ui/lib": "workspace:^",
"@leafygreen-ui/palette": "workspace:^",
"@leafygreen-ui/tabs": "workspace:^",
"@leafygreen-ui/tokens": "workspace:^",
"@leafygreen-ui/typography": "workspace:^",
"@lg-tools/test-harnesses": "workspace:^",
"polished": "^4.2.2"
"polished": "^4.2.2",
"react-intersection-observer": "^8.25.1"

},
"devDependencies": {
"@faker-js/faker": "^8.0.2",
"@leafygreen-ui/button": "workspace:^"
},
"peerDependencies": {
Expand Down
70 changes: 69 additions & 1 deletion packages/drawer/src/Drawer.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,33 @@
import React, { useState } from 'react';
import { faker } from '@faker-js/faker';
import { storybookArgTypes, StoryMetaType } from '@lg-tools/storybook-utils';
import { StoryObj } from '@storybook/react';
import { StoryFn, StoryObj } from '@storybook/react';

import Button from '@leafygreen-ui/button';
import { css } from '@leafygreen-ui/emotion';
import { Tab } from '@leafygreen-ui/tabs';
import { Body } from '@leafygreen-ui/typography';

import { DrawerTabs } from './DrawerTabs';
import { Drawer, DrawerProps } from '.';

const SEED = 0;
faker.seed(SEED);

export default {
title: 'Components/Drawer',
component: Drawer,
decorators: [
StoryFn => (
<div
className={css`
height: 100vh;
`}
>
<StoryFn />
</div>
),
],
parameters: {
default: 'LiveExample',
controls: {
Expand Down Expand Up @@ -53,6 +72,55 @@ export const LiveExample: StoryObj<DrawerProps> = {
},
};

const TemplateComponent: StoryFn<DrawerProps> = (args: DrawerProps) => {
const [open, setOpen] = useState(true);
return (
<div>
<Button onClick={() => setOpen(prevOpen => !prevOpen)}>
Open Drawer
</Button>
<Drawer {...args} open={open} setOpen={setOpen} />
</div>
);
};

const LongContent = () => (
<>
{faker.lorem
.paragraphs(20, '\n')
.split('\n')
.map((p, i) => (
<Body key={i}>{p}</Body>
))}
</>
);

export const Scroll: StoryObj<DrawerProps> = {
render: TemplateComponent,
args: {
children: <LongContent />,
},
};

export const WithTabs: StoryObj<DrawerProps> = {
render: TemplateComponent,
args: {
children: (
<DrawerTabs>
<Tab name="Tab 1">
<LongContent />
</Tab>
<Tab name="Tab 2">
<LongContent />
</Tab>
<Tab name="Tab 3">
<LongContent />
</Tab>
</DrawerTabs>
),
},
};

export const LightMode: StoryObj<DrawerProps> = {
render: () => <></>,
args: {
Expand Down
1 change: 1 addition & 0 deletions packages/drawer/src/Drawer/Drawer.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export const LGIDs = {
root: LGID_ROOT,
} as const;

export const HEADER_HEIGHT = 48;
export const PANEL_WIDTH = 432;
56 changes: 50 additions & 6 deletions packages/drawer/src/Drawer/Drawer.styles.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { transparentize } from 'polished';

import { css, cx } from '@leafygreen-ui/emotion';
import { Theme } from '@leafygreen-ui/lib';
import { color, transitionDuration } from '@leafygreen-ui/tokens';
import { palette } from '@leafygreen-ui/palette';
import { color, spacing, transitionDuration } from '@leafygreen-ui/tokens';

import { PANEL_WIDTH } from './Drawer.constants';
import { HEADER_HEIGHT, PANEL_WIDTH } from './Drawer.constants';

const getBaseStyles = ({ open, theme }: { open: boolean; theme: Theme }) => css`
height: 100%;
Expand Down Expand Up @@ -44,11 +47,52 @@ export const getDrawerStyles = ({
className,
);

export const getHeaderStyles = (theme: Theme) => css`
height: 48px;
padding: 16px;
const getBaseHeaderStyles = ({
hasTabs,
theme,
}: {
hasTabs: boolean;
theme: Theme;
}) => css`
height: ${HEADER_HEIGHT}px;
padding: ${spacing[400]}px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid ${color[theme].border.secondary.default};
border-bottom: ${hasTabs
? 'none'
: `1px solid ${color[theme].border.secondary.default}`};
transition: box-shadow ${transitionDuration.faster}ms ease-in-out;
`;

export const getShadowTopStyles = ({ theme }: { theme: Theme }) => css`
box-shadow: ${theme === Theme.Light
? `0px 10px 20px -10px ${transparentize(0.8, palette.black)}`
: '0px 6px 30px -10px rgba(0, 0, 0, 0.5)'};
`;

export const getHeaderStyles = ({
hasShadowTop,
hasTabs,
theme,
}: {
hasShadowTop: boolean;
hasTabs: boolean;
theme: Theme;
}) =>
cx(getBaseHeaderStyles({ hasTabs, theme }), {
[getShadowTopStyles({ theme })]: hasShadowTop && !hasTabs,
});

const baseChildrenContainerStyles = css`
height: calc(100% - ${HEADER_HEIGHT}px);
`;

export const scrollContainerStyles = css`
padding: ${spacing[400]}px;
overflow-y: auto;
overscroll-behavior: contain;
`;

export const getChildrenContainerStyles = ({ hasTabs }: { hasTabs: boolean }) =>
cx(baseChildrenContainerStyles, { [scrollContainerStyles]: !hasTabs });
80 changes: 53 additions & 27 deletions packages/drawer/src/Drawer/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { forwardRef } from 'react';
import React, { forwardRef, useState } from 'react';
import { useInView } from 'react-intersection-observer';

import { useIdAllocator } from '@leafygreen-ui/hooks';
import XIcon from '@leafygreen-ui/icon/dist/X';
Expand All @@ -9,8 +10,14 @@ import LeafyGreenProvider, {
import { BaseFontSize } from '@leafygreen-ui/tokens';
import { Body } from '@leafygreen-ui/typography';

import { DrawerContext } from '../DrawerContext';

import { LGIDs } from './Drawer.constants';
import { getDrawerStyles, getHeaderStyles } from './Drawer.styles';
import {
getChildrenContainerStyles,
getDrawerStyles,
getHeaderStyles,
} from './Drawer.styles';
import { DrawerProps } from './Drawer.types';

export const Drawer = forwardRef<HTMLDivElement, DrawerProps>(
Expand All @@ -29,39 +36,58 @@ export const Drawer = forwardRef<HTMLDivElement, DrawerProps>(
) => {
const { darkMode, theme } = useDarkMode();

const [hasTabs, setHasTabs] = useState(false);

const id = useIdAllocator({ prefix: 'drawer', id: idProp });
const titleId = useIdAllocator({ prefix: 'drawer' });

// Track when element is no longer visible to add shadow below drawer header
const { ref: interceptRef, inView: isInterceptInView } = useInView();

return (
<LeafyGreenProvider darkMode={darkMode}>
<div
aria-hidden={!open}
aria-labelledby={titleId}
className={getDrawerStyles({ className, open, theme })}
data-lgid={dataLgId}
id={id}
ref={fwdRef}
role="dialog"
{...rest}
<DrawerContext.Provider
value={{ registerTabs: () => setHasTabs(true) }}
>
<div className={getHeaderStyles(theme)}>
<Body
as={typeof title === 'string' ? 'h2' : 'div'}
baseFontSize={BaseFontSize.Body2}
id={titleId}
weight="medium"
>
{title}
</Body>
<IconButton
aria-label="Close drawer"
onClick={() => setOpen?.(false)}
<div
aria-hidden={!open}
aria-labelledby={titleId}
className={getDrawerStyles({ className, open, theme })}
data-lgid={dataLgId}
id={id}
ref={fwdRef}
role="dialog"
{...rest}
>
<div
className={getHeaderStyles({
hasShadowTop: !isInterceptInView,
hasTabs,
theme,
})}
>
<XIcon />
</IconButton>
<Body
as={typeof title === 'string' ? 'h2' : 'div'}
baseFontSize={BaseFontSize.Body2}
id={titleId}
weight="medium"
>
{title}
</Body>
<IconButton
aria-label="Close drawer"
onClick={() => setOpen?.(false)}
>
<XIcon />
</IconButton>
</div>
<div className={getChildrenContainerStyles({ hasTabs })}>
{/* Empty span element used to track if children container has scrolled down */}
{!hasTabs && <span ref={interceptRef} />}
{children}
</div>
</div>
{children}
</div>
</DrawerContext.Provider>
</LeafyGreenProvider>
);
},
Expand Down
1 change: 1 addition & 0 deletions packages/drawer/src/Drawer/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { Drawer } from './Drawer';
export { LGIDs } from './Drawer.constants';
export { getShadowTopStyles, scrollContainerStyles } from './Drawer.styles';
export { type DrawerProps } from './Drawer.types';
21 changes: 21 additions & 0 deletions packages/drawer/src/DrawerContext/DrawerContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createContext, useContext } from 'react';

export interface DrawerContextProps {
/**
* Callback invoked in the `DrawerTabs` component to provide context that a
* `DrawerTabs` instance is rendered in a `Drawer` instance
*/
registerTabs: () => void;
}

export const DrawerContext = createContext<DrawerContextProps | null>(null);

export const useDrawerContext = () => {
const context = useContext(DrawerContext);

if (!context) {
throw new Error('Component must be used within a Drawer');
}

return context;
};
5 changes: 5 additions & 0 deletions packages/drawer/src/DrawerContext/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export {
DrawerContext,
type DrawerContextProps,
useDrawerContext,
} from './DrawerContext';
1 change: 1 addition & 0 deletions packages/drawer/src/DrawerTabs/DrawerTabs.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const TAB_LIST_HEIGHT = 32;
Loading
Loading