Drawer: Resizable via draggable edge (#80796)

* Drawer: POC of draggable resizable drawer side

* Cleaner solution

* refinements

* refinements

* Add touch support
This commit is contained in:
Torkel Ödegaard 2024-01-25 07:54:32 +01:00 committed by GitHub
parent e08700c1b5
commit 05eb4fcd7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -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 = <div className={styles.content}>{children}</div>;
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 */}
<div className={styles.resizer} onMouseDown={onMouseDown} onTouchStart={onTouchStart} />
{typeof title === 'string' && (
<div className={cx(styles.header, Boolean(tabs) && styles.headerWithTabs)}>
<div className={styles.actions}>
@ -146,6 +149,63 @@ export function Drawer({
);
}
function useResizebleDrawer(): [
string | undefined,
React.EventHandler<React.MouseEvent>,
React.EventHandler<React.TouchEvent>,
] {
const [drawerWidth, setDrawerWidth] = useState<string | undefined>(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<HTMLDivElement>) {
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<HTMLDivElement>) {
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,
},
},
});
}