|
| 1 | +import { css, cx } from '@emotion/css'; |
| 2 | +import { useDialog } from '@react-aria/dialog'; |
| 3 | +import { FocusScope } from '@react-aria/focus'; |
| 4 | +import { useOverlay } from '@react-aria/overlays'; |
| 5 | +import RcDrawer from 'rc-drawer'; |
| 6 | +import { ReactNode, useCallback, useEffect, useState } from 'react'; |
| 7 | +import * as React from 'react'; |
| 8 | + |
| 9 | +import { GrafanaTheme2 } from '@grafana/data'; |
| 10 | +import { selectors } from '@grafana/e2e-selectors'; |
| 11 | +import { t } from '@grafana/i18n'; |
| 12 | + |
| 13 | +import 'rc-drawer/assets/index.css'; |
| 14 | +import { useStyles2, getDragStyles, IconButton, ScrollContainer, Text } from '@grafana/ui'; |
| 15 | + |
| 16 | +export interface Props { |
| 17 | + children: ReactNode; |
| 18 | + /** Title shown at the top of the drawer */ |
| 19 | + title?: ReactNode; |
| 20 | + /** Subtitle shown below the title */ |
| 21 | + subtitle?: ReactNode; |
| 22 | + /** Should the Drawer be closable by clicking on the mask, defaults to true */ |
| 23 | + closeOnMaskClick?: boolean; |
| 24 | + /** |
| 25 | + * Specifies the width and min-width. |
| 26 | + * sm = width 25% & min-width 384px |
| 27 | + * md = width 50% & min-width 568px |
| 28 | + * lg = width 75% & min-width 744px |
| 29 | + **/ |
| 30 | + size?: 'sm' | 'md' | 'lg'; |
| 31 | + /** Tabs */ |
| 32 | + tabs?: React.ReactNode; |
| 33 | + /** |
| 34 | + * Whether the content should be wrapped in a ScrollContainer |
| 35 | + * Only change this if you intend to manage scroll behaviour yourself |
| 36 | + * (e.g. having a split pane with independent scrolling) |
| 37 | + **/ |
| 38 | + scrollableContent?: boolean; |
| 39 | + /** Callback for closing the drawer */ |
| 40 | + onClose: () => void; |
| 41 | +} |
| 42 | + |
| 43 | +const drawerSizes = { |
| 44 | + sm: { width: '25%', minWidth: 384 }, |
| 45 | + md: { width: '50%', minWidth: 568 }, |
| 46 | + lg: { width: '75%', minWidth: 744 }, |
| 47 | +}; |
| 48 | + |
| 49 | +export function Drawer({ |
| 50 | + children, |
| 51 | + onClose, |
| 52 | + closeOnMaskClick = true, |
| 53 | + scrollableContent = true, |
| 54 | + title, |
| 55 | + subtitle, |
| 56 | + size = 'md', |
| 57 | + tabs, |
| 58 | +}: Props) { |
| 59 | + const [drawerWidth, onMouseDown, onTouchStart] = useResizebleDrawer(); |
| 60 | + |
| 61 | + const styles = useStyles2(getStyles); |
| 62 | + const wrapperStyles = useStyles2(getWrapperStyles, size); |
| 63 | + const dragStyles = useStyles2(getDragStyles); |
| 64 | + |
| 65 | + const overlayRef = React.useRef(null); |
| 66 | + const { dialogProps, titleProps } = useDialog({}, overlayRef); |
| 67 | + const { overlayProps } = useOverlay( |
| 68 | + { |
| 69 | + isDismissable: false, |
| 70 | + isOpen: true, |
| 71 | + onClose, |
| 72 | + }, |
| 73 | + overlayRef |
| 74 | + ); |
| 75 | + |
| 76 | + // Adds body class while open so the toolbar nav can hide some actions while drawer is open |
| 77 | + useBodyClassWhileOpen(); |
| 78 | + |
| 79 | + const content = <div className={styles.content}>{children}</div>; |
| 80 | + const overrideWidth = drawerWidth ?? drawerSizes[size].width; |
| 81 | + const minWidth = drawerSizes[size].minWidth; |
| 82 | + |
| 83 | + return ( |
| 84 | + <RcDrawer |
| 85 | + open={true} |
| 86 | + onClose={onClose} |
| 87 | + placement="right" |
| 88 | + getContainer={'#trace-exploration'} |
| 89 | + className={styles.drawerContent} |
| 90 | + rootClassName={styles.drawer} |
| 91 | + classNames={{ |
| 92 | + wrapper: wrapperStyles, |
| 93 | + }} |
| 94 | + styles={{ |
| 95 | + wrapper: { |
| 96 | + width: overrideWidth, |
| 97 | + minWidth, |
| 98 | + }, |
| 99 | + }} |
| 100 | + width={''} |
| 101 | + motion={{ |
| 102 | + motionAppear: true, |
| 103 | + motionName: styles.drawerMotion, |
| 104 | + }} |
| 105 | + maskClassName={styles.mask} |
| 106 | + maskClosable={closeOnMaskClick} |
| 107 | + maskMotion={{ |
| 108 | + motionAppear: true, |
| 109 | + motionName: styles.maskMotion, |
| 110 | + }} |
| 111 | + > |
| 112 | + <FocusScope restoreFocus contain autoFocus> |
| 113 | + <div |
| 114 | + aria-label={ |
| 115 | + typeof title === 'string' |
| 116 | + ? selectors.components.Drawer.General.title(title) |
| 117 | + : selectors.components.Drawer.General.title('no title') |
| 118 | + } |
| 119 | + className={styles.container} |
| 120 | + {...overlayProps} |
| 121 | + {...dialogProps} |
| 122 | + ref={overlayRef} |
| 123 | + > |
| 124 | + <div |
| 125 | + className={cx(dragStyles.dragHandleVertical, styles.resizer)} |
| 126 | + onMouseDown={onMouseDown} |
| 127 | + onTouchStart={onTouchStart} |
| 128 | + /> |
| 129 | + <div className={cx(styles.header, Boolean(tabs) && styles.headerWithTabs)}> |
| 130 | + <div className={styles.actions}> |
| 131 | + <IconButton |
| 132 | + name="times" |
| 133 | + variant="secondary" |
| 134 | + onClick={onClose} |
| 135 | + data-testid={selectors.components.Drawer.General.close} |
| 136 | + tooltip={t(`grafana-ui.drawer.close`, 'Close')} |
| 137 | + /> |
| 138 | + </div> |
| 139 | + {typeof title === 'string' ? ( |
| 140 | + <div className={styles.titleWrapper}> |
| 141 | + <Text element="h3" {...titleProps}> |
| 142 | + {title} |
| 143 | + </Text> |
| 144 | + {subtitle && ( |
| 145 | + <div className={styles.subtitle} data-testid={selectors.components.Drawer.General.subtitle}> |
| 146 | + {subtitle} |
| 147 | + </div> |
| 148 | + )} |
| 149 | + </div> |
| 150 | + ) : ( |
| 151 | + title |
| 152 | + )} |
| 153 | + {tabs && <div className={styles.tabsWrapper}>{tabs}</div>} |
| 154 | + </div> |
| 155 | + {!scrollableContent ? content : <ScrollContainer showScrollIndicators>{content}</ScrollContainer>} |
| 156 | + </div> |
| 157 | + </FocusScope> |
| 158 | + </RcDrawer> |
| 159 | + ); |
| 160 | +} |
| 161 | + |
| 162 | +function useResizebleDrawer(): [ |
| 163 | + string | undefined, |
| 164 | + React.EventHandler<React.MouseEvent>, |
| 165 | + React.EventHandler<React.TouchEvent>, |
| 166 | +] { |
| 167 | + const [drawerWidth, setDrawerWidth] = useState<string | undefined>(undefined); |
| 168 | + |
| 169 | + const onMouseMove = useCallback((e: MouseEvent) => { |
| 170 | + setDrawerWidth(getCustomDrawerWidth(e.clientX)); |
| 171 | + }, []); |
| 172 | + |
| 173 | + const onTouchMove = useCallback((e: TouchEvent) => { |
| 174 | + const touch = e.touches[0]; |
| 175 | + setDrawerWidth(getCustomDrawerWidth(touch.clientX)); |
| 176 | + }, []); |
| 177 | + |
| 178 | + const onMouseUp = useCallback( |
| 179 | + (e: MouseEvent) => { |
| 180 | + document.removeEventListener('mousemove', onMouseMove); |
| 181 | + document.removeEventListener('mouseup', onMouseUp); |
| 182 | + }, |
| 183 | + [onMouseMove] |
| 184 | + ); |
| 185 | + |
| 186 | + const onTouchEnd = useCallback( |
| 187 | + (e: TouchEvent) => { |
| 188 | + document.removeEventListener('touchmove', onTouchMove); |
| 189 | + document.removeEventListener('touchend', onTouchEnd); |
| 190 | + }, |
| 191 | + [onTouchMove] |
| 192 | + ); |
| 193 | + |
| 194 | + function onMouseDown(e: React.MouseEvent<HTMLDivElement>) { |
| 195 | + e.stopPropagation(); |
| 196 | + e.preventDefault(); |
| 197 | + // we will only add listeners when needed, and remove them afterward |
| 198 | + document.addEventListener('mousemove', onMouseMove); |
| 199 | + document.addEventListener('mouseup', onMouseUp); |
| 200 | + } |
| 201 | + |
| 202 | + function onTouchStart(e: React.TouchEvent<HTMLDivElement>) { |
| 203 | + e.stopPropagation(); |
| 204 | + e.preventDefault(); |
| 205 | + // we will only add listeners when needed, and remove them afterward |
| 206 | + document.addEventListener('touchmove', onTouchMove); |
| 207 | + document.addEventListener('touchend', onTouchEnd); |
| 208 | + } |
| 209 | + |
| 210 | + return [drawerWidth, onMouseDown, onTouchStart]; |
| 211 | +} |
| 212 | + |
| 213 | +function getCustomDrawerWidth(clientX: number) { |
| 214 | + let offsetRight = document.body.offsetWidth - (clientX - document.body.offsetLeft); |
| 215 | + let widthPercent = Math.min((offsetRight / document.body.clientWidth) * 100, 98).toFixed(2); |
| 216 | + return `${widthPercent}vw`; |
| 217 | +} |
| 218 | + |
| 219 | +function useBodyClassWhileOpen() { |
| 220 | + useEffect(() => { |
| 221 | + if (!document.body) { |
| 222 | + return; |
| 223 | + } |
| 224 | + |
| 225 | + document.body.classList.add('body-drawer-open'); |
| 226 | + |
| 227 | + return () => { |
| 228 | + document.body.classList.remove('body-drawer-open'); |
| 229 | + }; |
| 230 | + }, []); |
| 231 | +} |
| 232 | + |
| 233 | +const getStyles = (theme: GrafanaTheme2) => { |
| 234 | + return { |
| 235 | + container: css({ |
| 236 | + display: 'flex', |
| 237 | + flexDirection: 'column', |
| 238 | + height: '100%', |
| 239 | + flex: '1 1 0', |
| 240 | + minHeight: '100%', |
| 241 | + position: 'relative', |
| 242 | + }), |
| 243 | + drawer: css({ |
| 244 | + top: 0, |
| 245 | + position: 'absolute !important' as 'absolute', |
| 246 | + |
| 247 | + '.rc-drawer-content-wrapper': { |
| 248 | + boxShadow: theme.shadows.z3, |
| 249 | + }, |
| 250 | + }), |
| 251 | + drawerContent: css({ |
| 252 | + backgroundColor: `${theme.colors.background.primary} !important`, |
| 253 | + display: 'flex', |
| 254 | + overflow: 'unset !important', |
| 255 | + flexDirection: 'column', |
| 256 | + }), |
| 257 | + drawerMotion: css({ |
| 258 | + '&-appear': { |
| 259 | + transform: 'translateX(100%)', |
| 260 | + transition: 'none !important', |
| 261 | + |
| 262 | + '&-active': { |
| 263 | + transition: `${theme.transitions.create('transform')} !important`, |
| 264 | + transform: 'translateX(0)', |
| 265 | + }, |
| 266 | + }, |
| 267 | + }), |
| 268 | + // we want the mask itself to span the whole page including the top bar |
| 269 | + // this ensures trying to click something in the top bar will close the drawer correctly |
| 270 | + // but we don't want the backdrop styling to apply over the top bar as it looks weird |
| 271 | + // instead have a child pseudo element to apply the backdrop styling below the top bar |
| 272 | + mask: css({ |
| 273 | + // The !important here is to override the default .rc-drawer-mask styles |
| 274 | + backgroundColor: 'transparent !important', |
| 275 | + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions |
| 276 | + position: 'absolute !important' as 'absolute', |
| 277 | + |
| 278 | + '&:before': { |
| 279 | + backgroundColor: `${theme.components.overlay.background} !important`, |
| 280 | + bottom: 0, |
| 281 | + content: '""', |
| 282 | + left: 0, |
| 283 | + position: 'absolute', |
| 284 | + right: 0, |
| 285 | + top: 0, |
| 286 | + }, |
| 287 | + }), |
| 288 | + maskMotion: css({ |
| 289 | + '&-appear': { |
| 290 | + opacity: 0, |
| 291 | + |
| 292 | + '&-active': { |
| 293 | + opacity: 1, |
| 294 | + transition: theme.transitions.create('opacity'), |
| 295 | + }, |
| 296 | + }, |
| 297 | + }), |
| 298 | + header: css({ |
| 299 | + label: 'drawer-header', |
| 300 | + flexGrow: 0, |
| 301 | + padding: theme.spacing(2, 2, 3), |
| 302 | + borderBottom: `1px solid ${theme.colors.border.weak}`, |
| 303 | + }), |
| 304 | + headerWithTabs: css({ |
| 305 | + borderBottom: 'none', |
| 306 | + }), |
| 307 | + actions: css({ |
| 308 | + position: 'absolute', |
| 309 | + right: theme.spacing(1), |
| 310 | + top: theme.spacing(1), |
| 311 | + }), |
| 312 | + titleWrapper: css({ |
| 313 | + label: 'drawer-title', |
| 314 | + overflowWrap: 'break-word', |
| 315 | + }), |
| 316 | + subtitle: css({ |
| 317 | + label: 'drawer-subtitle', |
| 318 | + color: theme.colors.text.secondary, |
| 319 | + paddingTop: theme.spacing(1), |
| 320 | + }), |
| 321 | + content: css({ |
| 322 | + padding: theme.spacing(theme.components.drawer?.padding ?? 2), |
| 323 | + height: '100%', |
| 324 | + flexGrow: 1, |
| 325 | + minHeight: 0, |
| 326 | + }), |
| 327 | + tabsWrapper: css({ |
| 328 | + label: 'drawer-tabs', |
| 329 | + paddingLeft: theme.spacing(2), |
| 330 | + margin: theme.spacing(1, -1, -3, -3), |
| 331 | + }), |
| 332 | + resizer: css({ |
| 333 | + top: 0, |
| 334 | + left: theme.spacing(-1), |
| 335 | + bottom: 0, |
| 336 | + position: 'absolute', |
| 337 | + zIndex: theme.zIndex.modal, |
| 338 | + }), |
| 339 | + }; |
| 340 | +}; |
| 341 | + |
| 342 | +function getWrapperStyles(theme: GrafanaTheme2, size: 'sm' | 'md' | 'lg') { |
| 343 | + return css({ |
| 344 | + label: `drawer-content-wrapper-${size}`, |
| 345 | + overflow: 'unset !important', |
| 346 | + |
| 347 | + [theme.breakpoints.down('md')]: { |
| 348 | + width: `calc(100% - ${theme.spacing(2)}) !important`, |
| 349 | + minWidth: '0 !important', |
| 350 | + }, |
| 351 | + }); |
| 352 | +} |
0 commit comments