From 3e94231a93009cf16454094fb8a6bf501088b41b Mon Sep 17 00:00:00 2001 From: StepanKirichenko <asv-dgr-2@yandex-team.ru> Date: Wed, 17 Jul 2024 12:30:31 +0300 Subject: [PATCH 1/3] feat(Drawer): resizer, portal rendering, hide veil --- src/components/Drawer/Drawer.scss | 54 +++++++ src/components/Drawer/Drawer.tsx | 126 +++++++++++---- src/components/Drawer/README.md | 48 +++++- .../Drawer/__stories__/DisablePortal.scss | 43 +++++ .../Drawer/__stories__/DisablePortal.tsx | 60 +++++++ .../Drawer/__stories__/Drawer.stories.tsx | 12 ++ .../Drawer/__stories__/DrawerShowcase.scss | 1 - .../Drawer/__stories__/DrawerShowcase.tsx | 8 +- .../Drawer/__stories__/HideVeil.scss | 41 +++++ .../Drawer/__stories__/HideVeil.tsx | 43 +++++ .../Drawer/__stories__/ResizableItem.scss | 40 +++++ .../Drawer/__stories__/ResizableItem.tsx | 58 +++++++ src/components/Drawer/__stories__/moc.tsx | 22 +++ src/components/Drawer/utils.ts | 150 ++++++++++++++++++ 14 files changed, 668 insertions(+), 38 deletions(-) create mode 100644 src/components/Drawer/__stories__/DisablePortal.scss create mode 100644 src/components/Drawer/__stories__/DisablePortal.tsx create mode 100644 src/components/Drawer/__stories__/HideVeil.scss create mode 100644 src/components/Drawer/__stories__/HideVeil.tsx create mode 100644 src/components/Drawer/__stories__/ResizableItem.scss create mode 100644 src/components/Drawer/__stories__/ResizableItem.tsx create mode 100644 src/components/Drawer/__stories__/moc.tsx create mode 100644 src/components/Drawer/utils.ts diff --git a/src/components/Drawer/Drawer.scss b/src/components/Drawer/Drawer.scss index 7662a52..15280c0 100644 --- a/src/components/Drawer/Drawer.scss +++ b/src/components/Drawer/Drawer.scss @@ -3,6 +3,13 @@ $block: '.#{variables.$ns}drawer'; #{$block} { + pointer-events: none; + + --_--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; @@ -11,6 +18,7 @@ $block: '.#{variables.$ns}drawer'; height: 100%; will-change: transform; background-color: var(--g-color-base-background); + pointer-events: initial; &_direction_right { left: auto; @@ -64,8 +72,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 { @@ -89,4 +102,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: 8px; + 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) + ); + } } diff --git a/src/components/Drawer/Drawer.tsx b/src/components/Drawer/Drawer.tsx index 6d3c979..2be3543 100644 --- a/src/components/Drawer/Drawer.tsx +++ b/src/components/Drawer/Drawer.tsx @@ -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; @@ -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>; @@ -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> = ({ @@ -94,6 +150,8 @@ export const Drawer: React.FC<DrawerProps> = ({ onVeilClick, onEscape, preventScrollBody = true, + hideVeil, + disablePortal, }) => { let someItemVisible = false; React.Children.forEach(children, (child) => { @@ -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}} @@ -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 ( @@ -163,4 +225,12 @@ export const Drawer: React.FC<DrawerProps> = ({ }} </Transition> ); + + if (disablePortal) { + return drawer; + } + + return <Portal>{drawer}</Portal>; }; + +export type {DrawerDirection} from './utils'; diff --git a/src/components/Drawer/README.md b/src/components/Drawer/README.md index d1a5be4..269c32f 100644 --- a/src/components/Drawer/README.md +++ b/src/components/Drawer/README.md @@ -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 @@ -57,3 +87,5 @@ 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 hide the background darkening | `boolean` | | diff --git a/src/components/Drawer/__stories__/DisablePortal.scss b/src/components/Drawer/__stories__/DisablePortal.scss new file mode 100644 index 0000000..602f896 --- /dev/null +++ b/src/components/Drawer/__stories__/DisablePortal.scss @@ -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; + } +} diff --git a/src/components/Drawer/__stories__/DisablePortal.tsx b/src/components/Drawer/__stories__/DisablePortal.tsx new file mode 100644 index 0000000..7aca669 --- /dev/null +++ b/src/components/Drawer/__stories__/DisablePortal.tsx @@ -0,0 +1,60 @@ +import React from 'react'; + +import {Button, Checkbox, RadioButton} from '@gravity-ui/uikit'; + +import {cn} from '../../utils/cn'; +import {Drawer, DrawerItem} from '../Drawer'; + +import {PlaceholderText} from './moc'; + +import './DisablePortal.scss'; + +const b = cn('disable-portal'); + +export function DisablePortalShowcase() { + const [visible, setVisible] = React.useState(true); + const [direction, setDirection] = React.useState<'left' | 'right'>('right'); + const [disablePortal, setDisablePortal] = React.useState(true); + + return ( + <div className={b()}> + <div className={b('header')}> + <Button view="action" onClick={() => setVisible((v) => !v)}> + {visible ? 'Hide' : 'Show'} + </Button> + <RadioButton + value={direction} + options={[ + {value: 'left', content: 'Left'}, + {value: 'right', content: 'Right'}, + ]} + onUpdate={setDirection} + /> + <Checkbox + content="Disable protal" + checked={disablePortal} + onUpdate={(v) => setDisablePortal(v)} + /> + </div> + <div className={b('container')}> + <p>Container area for drawer with disablePortal</p> + <Drawer + className={b('drawer')} + disablePortal={disablePortal} + onVeilClick={() => setVisible(false)} + > + <DrawerItem + id="item" + direction={direction} + className={b('item')} + visible={visible} + > + <div className={b('item-content')} tabIndex={0}> + <PlaceholderText /> + </div> + </DrawerItem> + </Drawer> + </div> + </div> + ); +} diff --git a/src/components/Drawer/__stories__/Drawer.stories.tsx b/src/components/Drawer/__stories__/Drawer.stories.tsx index 64286dd..d912524 100644 --- a/src/components/Drawer/__stories__/Drawer.stories.tsx +++ b/src/components/Drawer/__stories__/Drawer.stories.tsx @@ -2,7 +2,10 @@ import React from 'react'; import type {Meta, StoryFn} from '@storybook/react'; +import {DisablePortalShowcase} from './DisablePortal'; import {DrawerShowcase} from './DrawerShowcase'; +import {HideVeilShowcase} from './HideVeil'; +import {ResizableItemShowcase} from './ResizableItem'; export default { title: 'components/Drawer', @@ -11,3 +14,12 @@ export default { const ShowcaseTemplate: StoryFn = () => <DrawerShowcase />; export const Showcase = ShowcaseTemplate.bind({}); + +const ResizableItemTemplate: StoryFn = () => <ResizableItemShowcase />; +export const ResizableItem = ResizableItemTemplate.bind({}); + +const DisablePortalTemplate: StoryFn = () => <DisablePortalShowcase />; +export const DisablePortal = DisablePortalTemplate.bind({}); + +const HideVeilTemplate: StoryFn = () => <HideVeilShowcase />; +export const HideVeil = HideVeilTemplate.bind({}); diff --git a/src/components/Drawer/__stories__/DrawerShowcase.scss b/src/components/Drawer/__stories__/DrawerShowcase.scss index 5f36c89..c9e9f0e 100644 --- a/src/components/Drawer/__stories__/DrawerShowcase.scss +++ b/src/components/Drawer/__stories__/DrawerShowcase.scss @@ -4,7 +4,6 @@ position: relative; &__header { - width: 100%; padding: 20px; border-bottom: 1px solid var(--g-color-line-generic); } diff --git a/src/components/Drawer/__stories__/DrawerShowcase.tsx b/src/components/Drawer/__stories__/DrawerShowcase.tsx index 64ecac8..2fad0a8 100644 --- a/src/components/Drawer/__stories__/DrawerShowcase.tsx +++ b/src/components/Drawer/__stories__/DrawerShowcase.tsx @@ -14,6 +14,7 @@ export function DrawerShowcase() { const [visible2, setVisible2] = React.useState<boolean>(false); const [direction, setDirection] = React.useState<string>('left'); + const [direction2, setDirection2] = React.useState<string>('left'); const hideAll = React.useCallback(() => { setVisible1(false); @@ -35,6 +36,11 @@ export function DrawerShowcase() { <RadioButton.Option value="left">left</RadioButton.Option> <RadioButton.Option value="right">right</RadioButton.Option> </RadioButton> + Direction2: + <RadioButton value={direction2} onUpdate={setDirection2}> + <RadioButton.Option value="left">left</RadioButton.Option> + <RadioButton.Option value="right">right</RadioButton.Option> + </RadioButton> </div> <Drawer className={b('drawer')} onVeilClick={hideAll} onEscape={hideAll}> <DrawerItem @@ -49,7 +55,7 @@ export function DrawerShowcase() { id="item-2" className={b('item-2')} content="" - direction={direction as DrawerItemProps['direction']} + direction={direction2 as DrawerItemProps['direction']} /> </Drawer> </div> diff --git a/src/components/Drawer/__stories__/HideVeil.scss b/src/components/Drawer/__stories__/HideVeil.scss new file mode 100644 index 0000000..b4095f4 --- /dev/null +++ b/src/components/Drawer/__stories__/HideVeil.scss @@ -0,0 +1,41 @@ +.hide-veil { + width: 100dvw; + height: 100dvh; + + display: flex; + flex-direction: column; + + &__header { + padding: 20px; + border-bottom: 1px solid var(--g-color-line-generic); + display: flex; + align-items: center; + gap: 8px; + } + + &__container { + position: relative; + flex-grow: 1; + padding: 16px; + } + + &__drawer { + position: absolute; + inset: 0; + overflow: hidden; + } + + &__item { + width: 400px; + height: 100%; + overflow-x: hidden; + } + + &__item-content { + box-sizing: border-box; + padding: 8px 16px; + border-left: 1px solid var(--g-color-line-generic); + height: 100%; + overflow-y: scroll; + } +} diff --git a/src/components/Drawer/__stories__/HideVeil.tsx b/src/components/Drawer/__stories__/HideVeil.tsx new file mode 100644 index 0000000..aa1fca2 --- /dev/null +++ b/src/components/Drawer/__stories__/HideVeil.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import {Button, Checkbox} from '@gravity-ui/uikit'; + +import {cn} from '../../utils/cn'; +import {Drawer, DrawerItem} from '../Drawer'; + +import {PlaceholderText} from './moc'; + +import './HideVeil.scss'; + +const b = cn('hide-veil'); + +export function HideVeilShowcase() { + const [visible, setVisible] = React.useState(true); + const [hideVeil, setHideVeil] = React.useState(true); + + return ( + <div className={b()}> + <div className={b('header')}> + <Button view="action" onClick={() => setVisible((v) => !v)}> + {visible ? 'Hide' : 'Show'} + </Button> + <Checkbox content="Hide veil" checked={hideVeil} onUpdate={setHideVeil} /> + </div> + <div className={b('container')}> + <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Porro, quos!</p> + <Drawer + className={b('drawer')} + hideVeil={hideVeil} + disablePortal + onVeilClick={() => setVisible(false)} + > + <DrawerItem id="item" className={b('item')} direction="right" visible={visible}> + <div className={b('item-content')} tabIndex={0}> + <PlaceholderText /> + </div> + </DrawerItem> + </Drawer> + </div> + </div> + ); +} diff --git a/src/components/Drawer/__stories__/ResizableItem.scss b/src/components/Drawer/__stories__/ResizableItem.scss new file mode 100644 index 0000000..f1cb476 --- /dev/null +++ b/src/components/Drawer/__stories__/ResizableItem.scss @@ -0,0 +1,40 @@ +.resizable-item { + width: 100dvw; + height: 100dvh; + + display: flex; + flex-direction: column; + + &__header { + padding: 20px; + border-bottom: 1px solid var(--g-color-line-generic); + display: flex; + align-items: center; + gap: 8px; + } + + &__container { + position: relative; + padding: 16px; + flex-grow: 1; + } + + &__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; + } +} diff --git a/src/components/Drawer/__stories__/ResizableItem.tsx b/src/components/Drawer/__stories__/ResizableItem.tsx new file mode 100644 index 0000000..e2aae31 --- /dev/null +++ b/src/components/Drawer/__stories__/ResizableItem.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import {Button, Checkbox, RadioButton} from '@gravity-ui/uikit'; + +import {cn} from '../../utils/cn'; +import {Drawer, DrawerItem} from '../Drawer'; + +import {PlaceholderText} from './moc'; + +import './ResizableItem.scss'; + +const b = cn('resizable-item'); + +export function ResizableItemShowcase() { + const [visible, setVisible] = React.useState(true); + const [direction, setDirection] = React.useState<'left' | 'right'>('right'); + const [resizable, setResizable] = React.useState(true); + const [width, setWidth] = React.useState(400); + + return ( + <div className={b()}> + <div className={b('header')}> + <Button view="action" onClick={() => setVisible((v) => !v)}> + {visible ? 'Hide' : 'Show'} + </Button> + <RadioButton + value={direction} + options={[ + {value: 'left', content: 'Left'}, + {value: 'right', content: 'Right'}, + ]} + onUpdate={setDirection} + /> + <Checkbox content="Resizable" checked={resizable} onUpdate={setResizable} /> + </div> + <div className={b('container')}> + <p>Lorem ipsum dolor, sit amet consectetur adipisicing elit. In, quidem.</p> + <Drawer className={b('drawer')} disablePortal onVeilClick={() => setVisible(false)}> + <DrawerItem + id="item" + direction={direction} + className={b('item')} + visible={visible} + resizable={resizable} + width={width} + onResize={setWidth} + minResizeWidth={300} + maxResizeWidth={800} + > + <div className={b('item-content')} tabIndex={0}> + <PlaceholderText /> + </div> + </DrawerItem> + </Drawer> + </div> + </div> + ); +} diff --git a/src/components/Drawer/__stories__/moc.tsx b/src/components/Drawer/__stories__/moc.tsx new file mode 100644 index 0000000..f757704 --- /dev/null +++ b/src/components/Drawer/__stories__/moc.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +export function PlaceholderText() { + return ( + <> + Lorem ipsum dolor sit amet consectetur, adipisicing elit. Deserunt iste dolores tenetur + perspiciatis nihil, dolorem corrupti veritatis quia odit dignissimos itaque quisquam ad + consequuntur voluptas odio totam similique quibusdam? Esse temporibus omnis pariatur + quas in? Iusto nesciunt dolor, voluptas placeat sed iure molestias repellendus id + officiis aliquam! Vero ad corporis distinctio, explicabo tempore reiciendis obcaecati + quaerat debitis inventore quidem fugit illum repellat deleniti soluta nihil iste commodi + labore at. Asperiores officiis accusamus accusantium, vitae nemo adipisci modi illum! + Exercitationem enim accusamus fuga totam quod minus itaque eius vitae modi aliquam + doloribus nostrum, nobis illo nisi inventore odio harum perspiciatis adipisci iusto. + Sapiente quo aliquam aut officiis quas odit iusto, quia accusantium voluptatibus qui + temporibus harum dicta? Dignissimos pariatur commodi, consectetur laborum, tempore porro + molestiae alias non dolores ab earum impedit. Placeat culpa quibusdam consequuntur + molestiae saepe nostrum laudantium delectus, doloremque provident ad corrupti mollitia, + expedita repellendus necessitatibus autem soluta aliquid. + </> + ); +} diff --git a/src/components/Drawer/utils.ts b/src/components/Drawer/utils.ts new file mode 100644 index 0000000..203a3a3 --- /dev/null +++ b/src/components/Drawer/utils.ts @@ -0,0 +1,150 @@ +import * as React from 'react'; + +export const DRAWER_ITEM_MIN_RESIZE_WIDTH = 200; +export const DRAWER_ITEM_MAX_RESIZE_WIDTH = 800; +export const DRAWER_ITEM_INITIAL_RESIZE_WIDTH = 400; + +export type DrawerDirection = 'right' | 'left'; +export type OnResizeHandler = (width: number) => void; + +function getEventClientX(e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent) { + return 'touches' in e ? e.touches[0]?.clientX ?? 0 : e.clientX; +} + +export interface UseResizeHandlersParams { + onStart: () => void; + onMove: (delta: number) => void; + onEnd: (delta: number) => void; +} + +export function useResizeHandlers({onStart, onMove, onEnd}: UseResizeHandlersParams) { + const initialXPosition = React.useRef(0); + const currentXPosition = React.useRef(0); + + const handleMove = React.useCallback( + (e: MouseEvent | TouchEvent) => { + const currentX = getEventClientX(e); + + if (currentXPosition.current === currentX) { + return; + } + + currentXPosition.current = currentX; + + const delta = initialXPosition.current - currentX; + + onMove(delta); + }, + [onMove], + ); + + const handleEnd = React.useCallback( + (e: MouseEvent | TouchEvent) => { + window.removeEventListener('mousemove', handleMove); + window.removeEventListener('touchmove', handleMove); + + document.body.style.removeProperty('user-select'); + document.body.style.removeProperty('-webkit-user-select'); + document.body.style.removeProperty('cursor'); + + const currentX = getEventClientX(e); + const delta = initialXPosition.current - currentX; + + onEnd(delta); + }, + [handleMove, onEnd], + ); + + const handleStart = React.useCallback( + (e: React.MouseEvent | React.TouchEvent) => { + const currentX = getEventClientX(e); + + initialXPosition.current = currentX; + currentXPosition.current = currentX; + + window.addEventListener('mouseup', handleEnd, {once: true}); + window.addEventListener('touchend', handleEnd, {once: true}); + window.addEventListener('touchcancel', handleEnd, {once: true}); + + window.addEventListener('mousemove', handleMove); + window.addEventListener('touchmove', handleMove); + + document.body.style.setProperty('user-select', 'none'); + document.body.style.setProperty('-webkit-user-select', 'none'); + document.body.style.setProperty('cursor', 'col-resize'); + + onStart(); + }, + [handleEnd, handleMove, onStart], + ); + + return { + onMouseDown: handleStart, + onTouchStart: handleStart, + }; +} + +export interface UseResizableDrawerItemParams { + direction?: DrawerDirection; + width?: number; + minResizeWidth?: number; + maxResizeWidth?: number; + onResize?: OnResizeHandler; +} + +export function useResizableDrawerItem(params: UseResizableDrawerItemParams) { + const { + direction, + width, + minResizeWidth = DRAWER_ITEM_MIN_RESIZE_WIDTH, + maxResizeWidth = DRAWER_ITEM_MAX_RESIZE_WIDTH, + onResize, + } = params; + + const [isResizing, setIsResizing] = React.useState(false); + const [resizeDelta, setResizeDelta] = React.useState(0); + const [internalWidth, setInternalWidth] = React.useState( + width ?? DRAWER_ITEM_INITIAL_RESIZE_WIDTH, + ); + + const getClampedWidth = React.useCallback( + (width: number) => Math.min(Math.max(width, minResizeWidth), maxResizeWidth), + [minResizeWidth, maxResizeWidth], + ); + + const getResizedWidth = React.useCallback( + (delta: number) => { + const signedDelta = direction === 'right' ? delta : -delta; + const newWidth = (width ?? internalWidth) + signedDelta; + return getClampedWidth(newWidth); + }, + [width, internalWidth, direction, getClampedWidth], + ); + + const onStart = React.useCallback(() => { + setIsResizing(true); + setResizeDelta(0); + }, [setIsResizing, setResizeDelta]); + + const onMove = React.useCallback((delta: number) => { + setResizeDelta(delta); + }, []); + + const onEnd = React.useCallback( + (delta: number) => { + const newWidth = getResizedWidth(delta); + setIsResizing(false); + setInternalWidth(newWidth); + onResize?.(newWidth); + }, + [setIsResizing, setInternalWidth, getResizedWidth, onResize], + ); + + const displayWidth = isResizing + ? getResizedWidth(resizeDelta) + : getClampedWidth(width ?? internalWidth); + + const handlers = useResizeHandlers({onStart, onMove, onEnd}); + + return {resizedWidth: displayWidth, resizerHandlers: handlers}; +} From 3cee0f880cac41e17140a82529f581370a559362 Mon Sep 17 00:00:00 2001 From: StepanKirichenko <asv-dgr-2@yandex-team.ru> Date: Thu, 18 Jul 2024 14:33:03 +0300 Subject: [PATCH 2/3] fix: code review --- src/components/Drawer/Drawer.scss | 3 ++- src/components/Drawer/Drawer.tsx | 2 -- src/components/Drawer/README.md | 11 +++++++++++ src/components/Drawer/index.ts | 2 ++ src/components/index.ts | 2 +- 5 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 src/components/Drawer/index.ts diff --git a/src/components/Drawer/Drawer.scss b/src/components/Drawer/Drawer.scss index 15280c0..92e4ef0 100644 --- a/src/components/Drawer/Drawer.scss +++ b/src/components/Drawer/Drawer.scss @@ -5,6 +5,7 @@ $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); @@ -121,7 +122,7 @@ $block: '.#{variables.$ns}drawer'; justify-content: center; align-items: center; - width: 8px; + width: var(--gn-drawer-item-resizer-width, var(--_--resizer-width)); height: 100%; cursor: col-resize; diff --git a/src/components/Drawer/Drawer.tsx b/src/components/Drawer/Drawer.tsx index 2be3543..30536b7 100644 --- a/src/components/Drawer/Drawer.tsx +++ b/src/components/Drawer/Drawer.tsx @@ -232,5 +232,3 @@ export const Drawer: React.FC<DrawerProps> = ({ return <Portal>{drawer}</Portal>; }; - -export type {DrawerDirection} from './utils'; diff --git a/src/components/Drawer/README.md b/src/components/Drawer/README.md index 269c32f..4e63e08 100644 --- a/src/components/Drawer/README.md +++ b/src/components/Drawer/README.md @@ -89,3 +89,14 @@ The Drawer module consists of two primary components: `Drawer` and `DrawerItem`. | 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 hide the background darkening | `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 | diff --git a/src/components/Drawer/index.ts b/src/components/Drawer/index.ts new file mode 100644 index 0000000..59f65bf --- /dev/null +++ b/src/components/Drawer/index.ts @@ -0,0 +1,2 @@ +export * from './Drawer'; +export type {DrawerDirection} from './utils'; diff --git a/src/components/index.ts b/src/components/index.ts index 8405b9e..ae85b73 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,11 +1,11 @@ export {AsideHeader} from './AsideHeader/AsideHeader'; export type {AsideHeaderProps} from './AsideHeader/types'; export {AsideHeaderContextProvider, useAsideHeaderContext} from './AsideHeader/AsideHeaderContext'; -export {Drawer, DrawerProps, DrawerItemProps, DrawerItem} from './Drawer/Drawer'; export {FooterItem, FooterItemProps} from './FooterItem/FooterItem'; export {PageLayout, type PageLayoutProps} from './AsideHeader/components/PageLayout/PageLayout'; export {PageLayoutAside} from './AsideHeader/components/PageLayout/PageLayoutAside'; export {AsideFallback} from './AsideHeader/components/PageLayout/AsideFallback'; +export * from './Drawer'; export * from './ActionBar'; export * from './Title'; export * from './HotkeysPanel'; From 6eaf889e74c3feeff4b6782b70c0959b161b7dfa Mon Sep 17 00:00:00 2001 From: StepanKirichenko <asv-dgr-2@yandex-team.ru> Date: Fri, 19 Jul 2024 11:16:45 +0300 Subject: [PATCH 3/3] fix: readme --- src/components/Drawer/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Drawer/README.md b/src/components/Drawer/README.md index 4e63e08..86d8afc 100644 --- a/src/components/Drawer/README.md +++ b/src/components/Drawer/README.md @@ -88,7 +88,7 @@ The Drawer module consists of two primary components: `Drawer` and `DrawerItem`. | 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 hide the background darkening | `boolean` | | +| disablePortal | Optional flag to not render drawer inside `Portal` | `boolean` | | ## CSS API