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

feat(Drawer): resizer, portal rendering, hide veil #270

Merged
merged 4 commits into from
Jul 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/components/Drawer/Drawer.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
$block: '.#{variables.$ns}drawer';

#{$block} {
pointer-events: none;

--_--resizer-width: 8px;
--_--resizer-color: var(--g-color-base-generic);
--_--resizer-handle-color: var(--g-color-line-generic);
--_--resizer-handle-color-hover: var(--g-color-line-generic-hover);
--_--resizer-z-index: 100;

&__item {
position: absolute;
left: 0;
Expand All @@ -11,6 +19,7 @@ $block: '.#{variables.$ns}drawer';
height: 100%;
will-change: transform;
background-color: var(--g-color-base-background);
pointer-events: initial;

&_direction_right {
left: auto;
Expand Down Expand Up @@ -64,8 +73,13 @@ $block: '.#{variables.$ns}drawer';

&__veil {
position: absolute;
pointer-events: initial;
inset: 0;
background-color: var(--g-color-sfx-veil);

&_hidden {
display: none;
}
}

&__veil-transition-enter {
Expand All @@ -89,4 +103,45 @@ $block: '.#{variables.$ns}drawer';
&__veil-transition-exit-done {
visibility: hidden;
}

&__resizer-handle {
width: 2px;
height: 28px;

background: var(--gn-drawer-item-resizer-handle-color, var(--_--resizer-handle-color));
border-radius: 2px;
}

&__resizer {
position: absolute;
top: 0;
z-index: var(--gn-drawer-item-resizer-z-index, var(--_--resizer-z-index));

display: flex;
flex-direction: column;
justify-content: center;
align-items: center;

width: var(--gn-drawer-item-resizer-width, var(--_--resizer-width));
height: 100%;

cursor: col-resize;

background: var(--gn-drawer-item-resizer-color, var(--_--resizer-color));

&_direction_right {
left: 0;
}

&_direction_left {
right: 0;
}
}

&__resizer:hover &__resizer-handle {
background: var(
--gn-drawer-item-resizer-handle-color-hover,
var(--_--resizer-handle-color-hover)
);
}
}
124 changes: 96 additions & 28 deletions src/components/Drawer/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import React from 'react';

import {useBodyScrollLock} from '@gravity-ui/uikit';
import {Portal, useBodyScrollLock, useForkRef} from '@gravity-ui/uikit';
import {CSSTransition, Transition} from 'react-transition-group';

import {block} from '../utils/cn';

import {type DrawerDirection, useResizableDrawerItem} from './utils';

import './Drawer.scss';

const b = block('drawer');
const TIMEOUT = 300;

export type DrawerDirection = 'right' | 'left';

export interface DrawerItemProps {
/** Unique identifier for the drawer item. */
id: string;
Expand All @@ -35,32 +35,82 @@ export interface DrawerItemProps {

/** Additional custom class name applied to the drawer item. */
className?: string;
}

export const DrawerItem: React.FC<DrawerItemProps> = ({
visible,
content,
children,
direction = 'left',
className,
}) => {
const itemRef = React.useRef<HTMLDivElement>(null);
const cssDirection = direction === 'left' ? undefined : direction;
/** Determines whether the drawer item can be resized */
resizable?: boolean;

return (
<CSSTransition
in={visible}
timeout={TIMEOUT}
unmountOnExit
classNames={b('item-transition', {direction: cssDirection})}
nodeRef={itemRef}
>
<div ref={itemRef} className={b('item', {direction: cssDirection}, className)}>
{children ?? content}
/**
* The width of the resizable drawer item.
* If not provided, the width will be stored internally.
*/
width?: number;

/**
* Called at the end of resizing. Can be used to save the new width.
* @param width The new width of the drawer item
*/
onResize?: (width: number) => void;

/** The minimum width of the resizable drawer item */
minResizeWidth?: number;

/** The maximum width of the resizable drawer item */
maxResizeWidth?: number;
}

export const DrawerItem = React.forwardRef<HTMLDivElement, DrawerItemProps>(
function DrawerItem(props, ref) {
const {
visible,
content,
children,
direction = 'left',
className,
resizable,
width,
minResizeWidth,
maxResizeWidth,
onResize,
} = props;

const itemRef = React.useRef<HTMLDivElement>(null);
const handleRef = useForkRef(ref, itemRef);
const cssDirection = direction === 'left' ? undefined : direction;

const {resizedWidth, resizerHandlers} = useResizableDrawerItem({
direction,
width,
minResizeWidth,
maxResizeWidth,
onResize,
});

const resizerElement = resizable ? (
<div className={b('resizer', {direction})} {...resizerHandlers}>
<div className={b('resizer-handle')} />
</div>
</CSSTransition>
);
};
) : null;

return (
<CSSTransition
in={visible}
timeout={TIMEOUT}
unmountOnExit
classNames={b('item-transition', {direction: cssDirection})}
nodeRef={itemRef}
>
<div
ref={handleRef}
className={b('item', {direction: cssDirection}, className)}
style={{width: resizable ? `${resizedWidth}px` : undefined}}
>
{resizerElement}
{children ?? content}
</div>
</CSSTransition>
);
},
);

type DrawerChild = React.ReactElement<DrawerItemProps>;

Expand All @@ -85,6 +135,12 @@ export interface DrawerProps {

/** Optional callback function that is called when the escape key is pressed, if the drawer is open. */
onEscape?: () => void;

/** Optional flag to hide the background darkening */
hideVeil?: boolean;

/** Optional flag to not use `Portal` for drawer */
disablePortal?: boolean;
}

export const Drawer: React.FC<DrawerProps> = ({
Expand All @@ -94,6 +150,8 @@ export const Drawer: React.FC<DrawerProps> = ({
onVeilClick,
onEscape,
preventScrollBody = true,
hideVeil,
disablePortal,
}) => {
let someItemVisible = false;
React.Children.forEach(children, (child) => {
Expand Down Expand Up @@ -124,7 +182,7 @@ export const Drawer: React.FC<DrawerProps> = ({
const containerRef = React.useRef<HTMLDivElement>(null);
const veilRef = React.useRef<HTMLDivElement>(null);

return (
const drawer = (
<Transition
in={someItemVisible}
timeout={{enter: 0, exit: TIMEOUT}}
Expand All @@ -143,7 +201,11 @@ export const Drawer: React.FC<DrawerProps> = ({
classNames={b('veil-transition')}
nodeRef={veilRef}
>
<div ref={veilRef} className={b('veil')} onClick={onVeilClick} />
<div
ref={veilRef}
className={b('veil', {hidden: hideVeil})}
onClick={onVeilClick}
/>
</CSSTransition>
{React.Children.map(children, (child) => {
if (
Expand All @@ -163,4 +225,10 @@ export const Drawer: React.FC<DrawerProps> = ({
}}
</Transition>
);

if (disablePortal) {
return drawer;
}

return <Portal>{drawer}</Portal>;
};
59 changes: 51 additions & 8 deletions src/components/Drawer/README.md
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,50 @@ const App = () => {
export default App;
```

An example of a resizable drawer item

```tsx
import React from 'react';
import {Drawer, DrawerItem} from '@gravity-ui/navigation';

const App = () => {
const [isVisible, setVisible] = React.useState(false);
const [width, setWidth] = React.useState(400);

return (
<div>
<button onClick={() => setVisible(true)}>Open Drawer</button>
<Drawer onEscape={() => setVisible(false)} onVeilClick={() => setVisible(false)}>
<DrawerItem id="item1" visible={isVisible} resizable width={width} onResize={setWidth}>
<p>Content of the drawer</p>
</DrawerItem>
</Drawer>
</div>
);
};

export default App;
```

## Components

The Drawer module consists of two primary components: `Drawer` and `DrawerItem`.

### `DrawerItem` Props

| Name | Description | Type | Default |
| :-------- | :-------------------------------------------------------------------------------------- | :---------------: | :-----: |
| id | Unique identifier for the drawer item. | `string` | |
| children | Content to be displayed within the drawer item, preferable over the deprecated content. | `React.ReactNode` | |
| content | (deprecated) use children. Content to be displayed within the drawer item. | `React.ReactNode` | |
| visible | Determines whether the drawer item is visible or hidden. | `boolean` | |
| direction | Specifies the direction from which the drawer should slide in (left or right). | `DrawerDirection` | `left` |
| className | HTML `class` attribute | `string` | |
| Name | Description | Type | Default |
| :------------- | :-------------------------------------------------------------------------------------- | :-----------------------: | :-----: |
| id | Unique identifier for the drawer item. | `string` | |
| children | Content to be displayed within the drawer item, preferable over the deprecated content. | `React.ReactNode` | |
| content | (deprecated) use children. Content to be displayed within the drawer item. | `React.ReactNode` | |
| visible | Determines whether the drawer item is visible or hidden. | `boolean` | |
| direction | Specifies the direction from which the drawer should slide in (left or right). | `DrawerDirection` | `left` |
| className | HTML `class` attribute | `string` | |
| resizable | Determines whether the drawer item can be resized | `boolean` | |
| width | The width of the resizable drawer item | `number` | |
| onResize | Callback function called at the end of resizing. Can be used to save the new width. | `(width: number) => void` | |
| minResizeWidth | The minimum width of the resizable drawer item | `number` | |
| maxResizeWidth | The maximum width of the resizable drawer item | `number` | |

### `Drawer` Props

Expand All @@ -57,3 +87,16 @@ The Drawer module consists of two primary components: `Drawer` and `DrawerItem`.
| style | Optional inline styles to be applied to the drawer component. | `React.CSSProperties` | |
| onVeilClick | Optional callback function that is called when the veil (overlay) is clicked. | `(event: React.MouseEvent) => void` | |
| onEscape | Optional callback function that is called when the escape key is pressed, if the drawer is open. | `() => void` | |
| hideVeil | Optional flag to hide the background darkening | `boolean` | |
| disablePortal | Optional flag to not render drawer inside `Portal` | `boolean` | |

## CSS API

| Name | Description | Default |
| :-------------------------------------------- | :---------------------------------------------------------- | :----------------------------: |
| Resizer | | |
| `--gn-drawer-item-resizer-width` | The width of the resizer element | 8px |
| `--gn-drawer-item-resizer-color` | The color of the resizer element | `--g-color-base-generic` |
| `--gn-drawer-item-resizer-handle-color` | The color of the resizer handle | `--g-color-line-generic` |
| `--gn-drawer-item-resizer-handle-color-hover` | The color of the resizer handle when the resizer is hovered | `--g-color-line-generic-hover` |
| `--gn-drawer-item-resizer-z-index` | z-index of the resizer element | 100 |
43 changes: 43 additions & 0 deletions src/components/Drawer/__stories__/DisablePortal.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
.disable-portal {
width: 100dvw;
height: 100dvh;

display: flex;
flex-direction: column;

&__header {
width: 100%;
padding: 20px;
border-bottom: 1px solid var(--g-color-line-generic);
display: flex;
align-items: center;
gap: 8px;
}

&__container {
position: relative;
padding: 16px;
width: 600px;
height: 400px;
border: 1px solid var(--g-color-line-brand);
}

&__drawer {
position: absolute;
inset: 0;
overflow: hidden;
}

&__item {
width: 400px;
height: 100%;
overflow-x: hidden;
}

&__item-content {
box-sizing: border-box;
padding: 8px 16px;
height: 100%;
overflow-y: scroll;
}
}
Loading
Loading