addons/isl/src/Drawers.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {ComponentClass} from 'react';
b69ab319import type {EnsureAssignedTogether} from 'shared/EnsureAssignedTogether';
b69ab3110
b69ab3111import {useAtom} from 'jotai';
b69ab3112import {createElement, useCallback, useRef, useState} from 'react';
b69ab3113import {islDrawerState} from './drawerState';
b69ab3114
b69ab3115import './Drawers.css';
b69ab3116
b69ab3117type NonNullReactElement = React.ReactElement | React.ReactFragment;
b69ab3118
b69ab3119enum Side {
b69ab3120 left = 'left',
b69ab3121 right = 'right',
b69ab3122 top = 'top',
b69ab3123 bottom = 'bottom',
b69ab3124}
b69ab3125
b69ab3126export type AllDrawersState = {[s in Side]: DrawerState};
b69ab3127export type DrawerState = {size: number; collapsed: boolean};
b69ab3128
b69ab3129export type ErrorBoundaryComponent = ComponentClass<
b69ab3130 {children: React.ReactNode},
b69ab3131 {error: Error | null}
b69ab3132>;
b69ab3133
b69ab3134export function Drawers({
b69ab3135 right,
b69ab3136 rightLabel,
b69ab3137 left,
b69ab3138 leftLabel,
b69ab3139 top,
b69ab3140 topLabel,
b69ab3141 bottom,
b69ab3142 bottomLabel,
b69ab3143 errorBoundary,
b69ab3144 children,
b69ab3145}: {
b69ab3146 errorBoundary: ErrorBoundaryComponent;
b69ab3147 children: React.ReactNode;
b69ab3148} & EnsureAssignedTogether<{left: NonNullReactElement; leftLabel: NonNullReactElement}> &
b69ab3149 EnsureAssignedTogether<{right: NonNullReactElement; rightLabel: NonNullReactElement}> &
b69ab3150 EnsureAssignedTogether<{top: NonNullReactElement; topLabel: NonNullReactElement}> &
b69ab3151 EnsureAssignedTogether<{bottom: NonNullReactElement; bottomLabel: NonNullReactElement}>) {
b69ab3152 return (
b69ab3153 <div className="drawers">
b69ab3154 {top ? (
b69ab3155 <Drawer side={Side.top} label={topLabel} errorBoundary={errorBoundary}>
b69ab3156 {top}
b69ab3157 </Drawer>
b69ab3158 ) : null}
b69ab3159 <div className="drawers-horizontal">
b69ab3160 {left ? (
b69ab3161 <Drawer side={Side.left} label={leftLabel} errorBoundary={errorBoundary}>
b69ab3162 {left}
b69ab3163 </Drawer>
b69ab3164 ) : null}
b69ab3165 <div className="drawer-main-content">{children}</div>
b69ab3166 {right ? (
b69ab3167 <Drawer side={Side.right} label={rightLabel} errorBoundary={errorBoundary}>
b69ab3168 {right}
b69ab3169 </Drawer>
b69ab3170 ) : null}
b69ab3171 </div>
b69ab3172
b69ab3173 {bottom ? (
b69ab3174 <Drawer side={Side.bottom} label={bottomLabel} errorBoundary={errorBoundary}>
b69ab3175 {bottom}
b69ab3176 </Drawer>
b69ab3177 ) : null}
b69ab3178 </div>
b69ab3179 );
b69ab3180}
b69ab3181
b69ab3182const stickyCollapseSizePx = 60;
b69ab3183const minDrawerSizePx = 100;
b69ab3184
b69ab3185export function Drawer({
b69ab3186 side,
b69ab3187 label,
b69ab3188 errorBoundary,
b69ab3189 children,
b69ab3190}: {
b69ab3191 side: Side;
b69ab3192 label: React.ReactNode;
b69ab3193 errorBoundary: ErrorBoundaryComponent;
b69ab3194 children: NonNullReactElement;
b69ab3195}) {
b69ab3196 const isVertical = side === 'top' || side === 'bottom';
b69ab3197 const dragHandleElement = useRef<HTMLDivElement>(null);
b69ab3198 const [isResizing, setIsResizing] = useState(false);
b69ab3199
b69ab31100 const [drawerState, setDrawerState] = useAtom(islDrawerState);
b69ab31101 const state = drawerState[side];
b69ab31102 const isExpanded = !state.collapsed;
b69ab31103
b69ab31104 const setInnerState = useCallback(
b69ab31105 (callback: (prevState: DrawerState) => DrawerState) =>
b69ab31106 setDrawerState(prev => ({...prev, [side]: callback(prev[side])})),
b69ab31107 [side, setDrawerState],
b69ab31108 );
cd704b9109 const drawerRef = useRef<HTMLDivElement>(null);
cd704b9110
b69ab31111 const startResizing = useCallback(
b69ab31112 (e: React.MouseEvent, initialWidth: number) => {
b69ab31113 e.preventDefault();
b69ab31114 const start = isVertical ? e.clientY : e.clientX;
b69ab31115 setIsResizing(true);
b69ab31116
cd704b9117 // During drag we mutate the DOM style directly to avoid React re-renders,
cd704b9118 // which cause jank when incoming server messages trigger state updates.
cd704b9119 const moveHandler = (newE: MouseEvent) => {
cd704b9120 const newPos = isVertical ? newE.clientY : newE.clientX;
cd704b9121 const maxDrawerSizePx = isVertical ? window.innerHeight : window.innerWidth;
cd704b9122 const newSize =
cd704b9123 side === 'right' || side === 'bottom'
cd704b9124 ? initialWidth - (newPos - start)
cd704b9125 : initialWidth + (newPos - start);
cd704b9126 const clampedSize = Math.min(maxDrawerSizePx, newSize);
cd704b9127 if (drawerRef.current) {
cd704b9128 drawerRef.current.style[isVertical ? 'height' : 'width'] = `${clampedSize}px`;
cd704b9129 }
cd704b9130 };
b69ab31131 window.addEventListener('mousemove', moveHandler);
b69ab31132
cd704b9133 const onMouseUp = (finalE: MouseEvent) => {
b69ab31134 setIsResizing(false);
b69ab31135 dispose?.();
b69ab31136 dispose = undefined;
cd704b9137
cd704b9138 // Commit the final size to React state
cd704b9139 const finalPos = isVertical ? finalE.clientY : finalE.clientX;
cd704b9140 const maxDrawerSizePx = isVertical ? window.innerHeight : window.innerWidth;
cd704b9141 const finalSize =
cd704b9142 side === 'right' || side === 'bottom'
cd704b9143 ? initialWidth - (finalPos - start)
cd704b9144 : initialWidth + (finalPos - start);
cd704b9145 setInnerState(() => ({
cd704b9146 size: Math.min(maxDrawerSizePx, finalSize),
cd704b9147 collapsed: finalSize > stickyCollapseSizePx ? false : true,
cd704b9148 }));
b69ab31149 };
b69ab31150
b69ab31151 let dispose: (() => void) | undefined = () => {
b69ab31152 window.removeEventListener('mousemove', moveHandler);
b69ab31153 window.removeEventListener('mouseup', onMouseUp);
b69ab31154 };
b69ab31155
b69ab31156 window.addEventListener('mouseup', onMouseUp);
b69ab31157 return dispose;
b69ab31158 },
b69ab31159 [isVertical, side, setInnerState],
b69ab31160 );
b69ab31161
b69ab31162 return (
b69ab31163 <div
cd704b9164 ref={drawerRef}
b69ab31165 className={`drawer drawer-${side}${isExpanded ? ' drawer-expanded' : ''}`}
b69ab31166 style={isExpanded ? {[isVertical ? 'height' : 'width']: `${state.size}px`} : undefined}>
b69ab31167 <div
b69ab31168 className="drawer-label"
b69ab31169 data-testid="drawer-label"
b69ab31170 onClick={() => {
b69ab31171 const maxDrawerSizePx = isVertical ? window.innerHeight : window.innerWidth;
b69ab31172 setDrawerState(prev => ({
b69ab31173 ...prev,
b69ab31174 [side]: {
b69ab31175 // enforce min/max size when expanding
b69ab31176 size: Math.min(maxDrawerSizePx, Math.max(minDrawerSizePx, prev[side].size)),
b69ab31177 collapsed: !prev[side].collapsed,
b69ab31178 },
b69ab31179 }));
b69ab31180 }}>
b69ab31181 {label}
b69ab31182 </div>
b69ab31183 {isExpanded ? (
b69ab31184 <>
b69ab31185 <div
b69ab31186 ref={dragHandleElement}
b69ab31187 className={`resizable-drag-handle${isResizing ? ' resizing' : ''}`}
b69ab31188 onMouseDown={(e: React.MouseEvent) => startResizing(e, state.size)}
b69ab31189 />
b69ab31190 {createElement(errorBoundary, null, children)}
b69ab31191 </>
b69ab31192 ) : null}
b69ab31193 </div>
b69ab31194 );
b69ab31195}