From 05eb4fcd7f90647b9663bbd51708c0f19a61b3cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 25 Jan 2024 07:54:32 +0100 Subject: [PATCH] Drawer: Resizable via draggable edge (#80796) * Drawer: POC of draggable resizable drawer side * Cleaner solution * refinements * refinements * Add touch support --- .../src/components/Drawer/Drawer.tsx | 184 +++++++++++++----- 1 file changed, 139 insertions(+), 45 deletions(-) diff --git a/packages/grafana-ui/src/components/Drawer/Drawer.tsx b/packages/grafana-ui/src/components/Drawer/Drawer.tsx index 4f455907e3e..ee8116d0ed8 100644 --- a/packages/grafana-ui/src/components/Drawer/Drawer.tsx +++ b/packages/grafana-ui/src/components/Drawer/Drawer.tsx @@ -3,7 +3,7 @@ import { useDialog } from '@react-aria/dialog'; import { FocusScope } from '@react-aria/focus'; import { useOverlay } from '@react-aria/overlays'; import RcDrawer from 'rc-drawer'; -import React, { ReactNode, useEffect } from 'react'; +import React, { ReactNode, useCallback, useEffect, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; @@ -37,7 +37,6 @@ export interface Props { * sm = width 25vw & min-width 384px * md = width 50vw & min-width 568px * lg = width 75vw & min-width 744px - * xl = width 85vw & min-width 744px **/ size?: 'sm' | 'md' | 'lg'; /** Tabs */ @@ -62,7 +61,11 @@ export function Drawer({ size = 'md', tabs, }: Props) { + const [drawerWidth, onMouseDown, onTouchStart] = useResizebleDrawer(); + const styles = useStyles2(getStyles); + const sizeStyles = useStyles2(getSizeStyles, size, drawerWidth ?? width); + const overlayRef = React.useRef(null); const { dialogProps, titleProps } = useDialog({}, overlayRef); const { overlayProps } = useOverlay( @@ -77,8 +80,7 @@ export function Drawer({ // Adds body class while open so the toolbar nav can hide some actions while drawer is open useBodyClassWhileOpen(); - // Apply size styles (unless deprecated width prop is used) - const rootClass = cx(styles.drawer, !width && styles.sizes[size]); + const rootClass = cx(styles.drawer, sizeStyles); const content =
{children}
; return ( @@ -86,11 +88,10 @@ export function Drawer({ open={true} onClose={onClose} placement="right" - // Important to set this to empty string so that the width can be controlled by the css - width={width ?? ''} getContainer={'.main-view'} className={styles.drawerContent} rootClassName={rootClass} + width={''} motion={{ motionAppear: true, motionName: styles.drawerMotion, @@ -114,6 +115,8 @@ export function Drawer({ {...dialogProps} ref={overlayRef} > + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
{typeof title === 'string' && (
@@ -146,6 +149,63 @@ export function Drawer({ ); } +function useResizebleDrawer(): [ + string | undefined, + React.EventHandler, + React.EventHandler, +] { + const [drawerWidth, setDrawerWidth] = useState(undefined); + + const onMouseMove = useCallback((e: MouseEvent) => { + setDrawerWidth(getCustomDrawerWidth(e.clientX)); + }, []); + + const onTouchMove = useCallback((e: TouchEvent) => { + const touch = e.touches[0]; + setDrawerWidth(getCustomDrawerWidth(touch.clientX)); + }, []); + + const onMouseUp = useCallback( + (e: MouseEvent) => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }, + [onMouseMove] + ); + + const onTouchEnd = useCallback( + (e: TouchEvent) => { + document.removeEventListener('touchmove', onTouchMove); + document.removeEventListener('touchend', onTouchEnd); + }, + [onTouchMove] + ); + + function onMouseDown(e: React.MouseEvent) { + e.stopPropagation(); + e.preventDefault(); + // we will only add listeners when needed, and remove them afterward + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + } + + function onTouchStart(e: React.TouchEvent) { + e.stopPropagation(); + e.preventDefault(); + // we will only add listeners when needed, and remove them afterward + document.addEventListener('touchmove', onTouchMove); + document.addEventListener('touchend', onTouchEnd); + } + + return [drawerWidth, onMouseDown, onTouchStart]; +} + +function getCustomDrawerWidth(clientX: number) { + let offsetRight = document.body.offsetWidth - (clientX - document.body.offsetLeft); + let widthPercent = Math.min((offsetRight / document.body.clientWidth) * 100, 98).toFixed(2); + return `${widthPercent}vw`; +} + function useBodyClassWhileOpen() { useEffect(() => { if (!document.body) { @@ -168,14 +228,15 @@ const getStyles = (theme: GrafanaTheme2) => { height: '100%', flex: '1 1 0', minHeight: '100%', + position: 'relative', }), drawer: css({ '.main-view &': { - top: 81, + top: 80, }, '.main-view--search-bar-hidden &': { - top: 41, + top: 40, }, '.main-view--chrome-hidden &': { @@ -184,47 +245,13 @@ const getStyles = (theme: GrafanaTheme2) => { '.rc-drawer-content-wrapper': { boxShadow: theme.shadows.z3, - - [theme.breakpoints.down('sm')]: { - width: `calc(100% - ${theme.spacing(2)}) !important`, - minWidth: '0 !important', - }, }, }), - sizes: { - sm: css({ - '.rc-drawer-content-wrapper': { - label: 'drawer-sm', - width: '25vw', - minWidth: theme.spacing(48), - }, - }), - md: css({ - '.rc-drawer-content-wrapper': { - label: 'drawer-md', - width: '50vw', - minWidth: theme.spacing(60), - }, - }), - lg: css({ - '.rc-drawer-content-wrapper': { - label: 'drawer-lg', - width: '85vw', - minWidth: theme.spacing(93), - - [theme.breakpoints.down('md')]: { - width: `calc(100% - ${theme.spacing(2)}) !important`, - minWidth: 0, - }, - }, - }), - }, drawerContent: css({ backgroundColor: `${theme.colors.background.primary} !important`, display: 'flex', + overflow: 'unset', flexDirection: 'column', - overflow: 'hidden', - zIndex: theme.zIndex.dropdown, }), drawerMotion: css({ '&-appear': { @@ -255,11 +282,11 @@ const getStyles = (theme: GrafanaTheme2) => { right: 0, '.main-view &': { - top: 81, + top: 80, }, '.main-view--search-bar-hidden &': { - top: 41, + top: 40, }, '.main-view--chrome-hidden &': { @@ -310,5 +337,72 @@ const getStyles = (theme: GrafanaTheme2) => { paddingLeft: theme.spacing(2), margin: theme.spacing(1, -1, -3, -3), }), + resizer: css({ + width: 8, + top: 0, + left: -4, + bottom: 0, + position: 'absolute', + cursor: 'col-resize', + zIndex: theme.zIndex.modal, + + '&::after': { + background: theme.colors.emphasize(theme.colors.background.secondary, 0.15), + content: '""', + position: 'absolute', + left: '50%', + top: '50%', + transition: '0.2s background ease-in-out', + transform: 'translate(-50%, -50%)', + borderRadius: theme.shape.radius.default, + height: '200px', + width: '4px', + }, + + '&::before': { + content: '""', + position: 'absolute', + transition: '0.2s border-color ease-in-out', + borderRight: '2px solid transparent', + height: '100%', + left: '50%', + transform: 'translateX(-50%)', + }, + + '&:hover': { + '&::before': { + borderColor: theme.colors.primary.border, + }, + + '&::after': { + background: theme.colors.primary.border, + }, + }, + }), }; }; + +const drawerSizes = { + sm: { width: '25vw', minWidth: 384 }, + md: { width: '50vw', minWidth: 568 }, + lg: { width: '75vw', minWidth: 744 }, +}; + +function getSizeStyles(theme: GrafanaTheme2, size: 'sm' | 'md' | 'lg', overrideWidth: number | string | undefined) { + let width = overrideWidth ?? drawerSizes[size].width; + let minWidth = drawerSizes[size].minWidth; + + return css({ + '.rc-drawer-content-wrapper': { + label: `drawer-content-wrapper-${size}`, + width: width, + minWidth: minWidth, + overflow: 'unset', + + [theme.breakpoints.down('md')]: { + width: `calc(100% - ${theme.spacing(2)}) !important`, + minWidth: 0, + }, + }, + }); +}