6.0 KB196 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {ComponentClass} from 'react';
9import type {EnsureAssignedTogether} from 'shared/EnsureAssignedTogether';
10
11import {useAtom} from 'jotai';
12import {createElement, useCallback, useRef, useState} from 'react';
13import {islDrawerState} from './drawerState';
14
15import './Drawers.css';
16
17type NonNullReactElement = React.ReactElement | React.ReactFragment;
18
19enum Side {
20 left = 'left',
21 right = 'right',
22 top = 'top',
23 bottom = 'bottom',
24}
25
26export type AllDrawersState = {[s in Side]: DrawerState};
27export type DrawerState = {size: number; collapsed: boolean};
28
29export type ErrorBoundaryComponent = ComponentClass<
30 {children: React.ReactNode},
31 {error: Error | null}
32>;
33
34export function Drawers({
35 right,
36 rightLabel,
37 left,
38 leftLabel,
39 top,
40 topLabel,
41 bottom,
42 bottomLabel,
43 errorBoundary,
44 children,
45}: {
46 errorBoundary: ErrorBoundaryComponent;
47 children: React.ReactNode;
48} & EnsureAssignedTogether<{left: NonNullReactElement; leftLabel: NonNullReactElement}> &
49 EnsureAssignedTogether<{right: NonNullReactElement; rightLabel: NonNullReactElement}> &
50 EnsureAssignedTogether<{top: NonNullReactElement; topLabel: NonNullReactElement}> &
51 EnsureAssignedTogether<{bottom: NonNullReactElement; bottomLabel: NonNullReactElement}>) {
52 return (
53 <div className="drawers">
54 {top ? (
55 <Drawer side={Side.top} label={topLabel} errorBoundary={errorBoundary}>
56 {top}
57 </Drawer>
58 ) : null}
59 <div className="drawers-horizontal">
60 {left ? (
61 <Drawer side={Side.left} label={leftLabel} errorBoundary={errorBoundary}>
62 {left}
63 </Drawer>
64 ) : null}
65 <div className="drawer-main-content">{children}</div>
66 {right ? (
67 <Drawer side={Side.right} label={rightLabel} errorBoundary={errorBoundary}>
68 {right}
69 </Drawer>
70 ) : null}
71 </div>
72
73 {bottom ? (
74 <Drawer side={Side.bottom} label={bottomLabel} errorBoundary={errorBoundary}>
75 {bottom}
76 </Drawer>
77 ) : null}
78 </div>
79 );
80}
81
82const stickyCollapseSizePx = 60;
83const minDrawerSizePx = 100;
84
85export function Drawer({
86 side,
87 label,
88 errorBoundary,
89 children,
90}: {
91 side: Side;
92 label: React.ReactNode;
93 errorBoundary: ErrorBoundaryComponent;
94 children: NonNullReactElement;
95}) {
96 const isVertical = side === 'top' || side === 'bottom';
97 const dragHandleElement = useRef<HTMLDivElement>(null);
98 const [isResizing, setIsResizing] = useState(false);
99
100 const [drawerState, setDrawerState] = useAtom(islDrawerState);
101 const state = drawerState[side];
102 const isExpanded = !state.collapsed;
103
104 const setInnerState = useCallback(
105 (callback: (prevState: DrawerState) => DrawerState) =>
106 setDrawerState(prev => ({...prev, [side]: callback(prev[side])})),
107 [side, setDrawerState],
108 );
109 const drawerRef = useRef<HTMLDivElement>(null);
110
111 const startResizing = useCallback(
112 (e: React.MouseEvent, initialWidth: number) => {
113 e.preventDefault();
114 const start = isVertical ? e.clientY : e.clientX;
115 setIsResizing(true);
116
117 // During drag we mutate the DOM style directly to avoid React re-renders,
118 // which cause jank when incoming server messages trigger state updates.
119 const moveHandler = (newE: MouseEvent) => {
120 const newPos = isVertical ? newE.clientY : newE.clientX;
121 const maxDrawerSizePx = isVertical ? window.innerHeight : window.innerWidth;
122 const newSize =
123 side === 'right' || side === 'bottom'
124 ? initialWidth - (newPos - start)
125 : initialWidth + (newPos - start);
126 const clampedSize = Math.min(maxDrawerSizePx, newSize);
127 if (drawerRef.current) {
128 drawerRef.current.style[isVertical ? 'height' : 'width'] = `${clampedSize}px`;
129 }
130 };
131 window.addEventListener('mousemove', moveHandler);
132
133 const onMouseUp = (finalE: MouseEvent) => {
134 setIsResizing(false);
135 dispose?.();
136 dispose = undefined;
137
138 // Commit the final size to React state
139 const finalPos = isVertical ? finalE.clientY : finalE.clientX;
140 const maxDrawerSizePx = isVertical ? window.innerHeight : window.innerWidth;
141 const finalSize =
142 side === 'right' || side === 'bottom'
143 ? initialWidth - (finalPos - start)
144 : initialWidth + (finalPos - start);
145 setInnerState(() => ({
146 size: Math.min(maxDrawerSizePx, finalSize),
147 collapsed: finalSize > stickyCollapseSizePx ? false : true,
148 }));
149 };
150
151 let dispose: (() => void) | undefined = () => {
152 window.removeEventListener('mousemove', moveHandler);
153 window.removeEventListener('mouseup', onMouseUp);
154 };
155
156 window.addEventListener('mouseup', onMouseUp);
157 return dispose;
158 },
159 [isVertical, side, setInnerState],
160 );
161
162 return (
163 <div
164 ref={drawerRef}
165 className={`drawer drawer-${side}${isExpanded ? ' drawer-expanded' : ''}`}
166 style={isExpanded ? {[isVertical ? 'height' : 'width']: `${state.size}px`} : undefined}>
167 <div
168 className="drawer-label"
169 data-testid="drawer-label"
170 onClick={() => {
171 const maxDrawerSizePx = isVertical ? window.innerHeight : window.innerWidth;
172 setDrawerState(prev => ({
173 ...prev,
174 [side]: {
175 // enforce min/max size when expanding
176 size: Math.min(maxDrawerSizePx, Math.max(minDrawerSizePx, prev[side].size)),
177 collapsed: !prev[side].collapsed,
178 },
179 }));
180 }}>
181 {label}
182 </div>
183 {isExpanded ? (
184 <>
185 <div
186 ref={dragHandleElement}
187 className={`resizable-drag-handle${isResizing ? ' resizing' : ''}`}
188 onMouseDown={(e: React.MouseEvent) => startResizing(e, state.size)}
189 />
190 {createElement(errorBoundary, null, children)}
191 </>
192 ) : null}
193 </div>
194 );
195}
196