Skip to content

Commit 9a88718

Browse files
committed
feat(components, data-modeling): add portal version of the Drawer component; use new DrawerSection in data-modeling
1 parent 01668f8 commit 9a88718

File tree

9 files changed

+352
-70
lines changed

9 files changed

+352
-70
lines changed

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/compass-components/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
"mongodb-query-util": "^2.5.2",
9292
"polished": "^4.2.2",
9393
"react": "^17.0.2",
94+
"react-dom": "^17.0.2",
9495
"react-hotkeys-hook": "^4.3.7",
9596
"react-intersection-observer": "^8.34.0",
9697
"react-virtualized-auto-sizer": "^1.0.6",
@@ -110,7 +111,6 @@
110111
"chai": "^4.3.4",
111112
"mocha": "^10.2.0",
112113
"nyc": "^15.1.0",
113-
"react-dom": "^17.0.2",
114114
"sinon": "^9.0.0",
115115
"typescript": "^5.8.3"
116116
},

packages/compass-components/src/components/compass-components-provider.tsx

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { SignalHooksProvider } from './signal-popover';
77
import { RequiredURLSearchParamsProvider } from './links/link';
88
import { StackedComponentProvider } from '../hooks/use-stacked-component';
99
import { ContextMenuProvider } from './context-menu';
10+
import { DrawerContentProvider } from './drawer-portal';
1011

1112
type GuideCueProviderProps = React.ComponentProps<typeof GuideCueProvider>;
1213

@@ -131,33 +132,35 @@ export const CompassComponentsProvider = ({
131132
darkMode={darkMode}
132133
popoverPortalContainer={popoverPortalContainer}
133134
>
134-
<StackedComponentProvider zIndex={stackedElementsZIndex}>
135-
<RequiredURLSearchParamsProvider
136-
utmSource={utmSource}
137-
utmMedium={utmMedium}
138-
>
139-
<GuideCueProvider
140-
onNext={onNextGuideGue}
141-
onNextGroup={onNextGuideCueGroup}
135+
<DrawerContentProvider>
136+
<StackedComponentProvider zIndex={stackedElementsZIndex}>
137+
<RequiredURLSearchParamsProvider
138+
utmSource={utmSource}
139+
utmMedium={utmMedium}
142140
>
143-
<SignalHooksProvider {...signalHooksProviderProps}>
144-
<ConfirmationModalArea>
145-
<ContextMenuProvider disabled={disableContextMenus}>
146-
<ToastArea>
147-
{typeof children === 'function'
148-
? children({
149-
darkMode,
150-
portalContainerRef: setPortalContainer,
151-
scrollContainerRef: setScrollContainer,
152-
})
153-
: children}
154-
</ToastArea>
155-
</ContextMenuProvider>
156-
</ConfirmationModalArea>
157-
</SignalHooksProvider>
158-
</GuideCueProvider>
159-
</RequiredURLSearchParamsProvider>
160-
</StackedComponentProvider>
141+
<GuideCueProvider
142+
onNext={onNextGuideGue}
143+
onNextGroup={onNextGuideCueGroup}
144+
>
145+
<SignalHooksProvider {...signalHooksProviderProps}>
146+
<ConfirmationModalArea>
147+
<ContextMenuProvider disabled={disableContextMenus}>
148+
<ToastArea>
149+
{typeof children === 'function'
150+
? children({
151+
darkMode,
152+
portalContainerRef: setPortalContainer,
153+
scrollContainerRef: setScrollContainer,
154+
})
155+
: children}
156+
</ToastArea>
157+
</ContextMenuProvider>
158+
</ConfirmationModalArea>
159+
</SignalHooksProvider>
160+
</GuideCueProvider>
161+
</RequiredURLSearchParamsProvider>
162+
</StackedComponentProvider>
163+
</DrawerContentProvider>
161164
</LeafyGreenProvider>
162165
);
163166
};
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import ReactDOM from 'react-dom';
2+
import React, {
3+
useContext,
4+
useEffect,
5+
useLayoutEffect,
6+
useRef,
7+
useState,
8+
} from 'react';
9+
10+
import {
11+
DrawerLayout,
12+
DisplayMode as DrawerDisplayMode,
13+
useDrawerToolbarContext,
14+
type DrawerLayoutProps,
15+
} from './drawer';
16+
import { css, cx } from '@leafygreen-ui/emotion';
17+
import { isEqual } from 'lodash';
18+
import { rafraf } from '../utils/rafraf';
19+
20+
type SectionData = Required<DrawerLayoutProps>['toolbarData'][number];
21+
22+
type DrawerSectionProps = Omit<SectionData, 'content' | 'onClick'> & {
23+
autoOpen?: boolean;
24+
};
25+
26+
type DrawerActionsContextValue = {
27+
current: {
28+
openDrawer: (id: string) => void;
29+
closeDrawer: () => void;
30+
updateToolbarData: (data: DrawerSectionProps) => void;
31+
removeToolbarData: (id: string) => void;
32+
};
33+
};
34+
35+
const DrawerStateContext = React.createContext<DrawerSectionProps[]>([]);
36+
37+
const DrawerActionsContext = React.createContext<DrawerActionsContextValue>({
38+
current: {
39+
openDrawer: () => undefined,
40+
closeDrawer: () => undefined,
41+
updateToolbarData: () => undefined,
42+
removeToolbarData: () => undefined,
43+
},
44+
});
45+
46+
/**
47+
* Drawer component that keeps track of drawer rendering state and provides
48+
* context to all places that require it. Separating it from DrawerAnchor and
49+
* DrawerSection allows to freely move the actual drawer around while allowing
50+
* the whole application access to the Drawer state, not only parts of it
51+
* wrapped in the Drawer
52+
*
53+
* @example
54+
*
55+
* function App() {
56+
* return (
57+
* <DrawerContentProvider>
58+
* <DrawerAnchor>
59+
* <Content></Content>
60+
* </DrawerAnchor>
61+
* </DrawerContentProvider>
62+
* )
63+
* }
64+
*
65+
* function Content() {
66+
* const [showDrawerSection, setShowDrawerSection] = useState(false);
67+
* return (
68+
* <>
69+
* <button onClick={() => setShowDrawerSection(true)}></button>
70+
* {showDrawerSection &&
71+
* <DrawerSection id="section-1" title="Drawer Title">
72+
* This will be rendered inside the drawer
73+
* </>
74+
* )}
75+
* </>
76+
* )
77+
* }
78+
*/
79+
export const DrawerContentProvider: React.FunctionComponent = ({
80+
children,
81+
}) => {
82+
const [drawerState, setDrawerState] = useState<DrawerSectionProps[]>([]);
83+
const drawerActions = useRef({
84+
openDrawer: () => undefined,
85+
closeDrawer: () => undefined,
86+
updateToolbarData: (data: DrawerSectionProps) => {
87+
setDrawerState((prevState) => {
88+
const itemIndex = prevState.findIndex((item) => {
89+
return item.id === data.id;
90+
});
91+
if (itemIndex === -1) {
92+
return [...prevState, data];
93+
}
94+
const newState = [...prevState];
95+
newState[itemIndex] = data;
96+
return newState;
97+
});
98+
},
99+
removeToolbarData: (id: string) => {
100+
setDrawerState((prevState) => {
101+
return prevState.filter((data) => {
102+
return data.id !== id;
103+
});
104+
});
105+
},
106+
});
107+
108+
return (
109+
<DrawerStateContext.Provider value={drawerState}>
110+
<DrawerActionsContext.Provider value={drawerActions}>
111+
{children}
112+
</DrawerActionsContext.Provider>
113+
</DrawerStateContext.Provider>
114+
);
115+
};
116+
117+
const DrawerContextGrabber: React.FunctionComponent = ({ children }) => {
118+
const drawerToolbarContext = useDrawerToolbarContext();
119+
const actions = useContext(DrawerActionsContext);
120+
actions.current.openDrawer = drawerToolbarContext.openDrawer;
121+
actions.current.closeDrawer = drawerToolbarContext.closeDrawer;
122+
return <>{children}</>;
123+
};
124+
125+
// Leafygreen Drawer gets right in the middle of our layout messing up most of
126+
// the expectations for the workspace layouting. We override those to make them
127+
// more flexible
128+
const drawerLayoutFixesStyles = css({
129+
// content section
130+
'& > div:nth-child(1)': {
131+
display: 'flex',
132+
alignItems: 'stretch',
133+
overflow: 'auto',
134+
},
135+
136+
// drawer section
137+
'& > div:nth-child(2)': {
138+
marginTop: -1, // hiding the top border as we already have one in the place where the Anchor is currently rendered
139+
},
140+
});
141+
142+
const emptyDrawerLayoutFixesStyles = css({
143+
// Otherwise causes a weird content animation when the drawer becomes empty,
144+
// the only way not to have this oterwise is to always keep the drawer toolbar
145+
// on the screen and this eats up precious screen space
146+
transition: 'none',
147+
// Leafygreen removes areas when there are no drawer sections and this just
148+
// completely breaks the grid and messes up the layout
149+
gridTemplateAreas: '"content drawer"',
150+
// Bug in leafygreen where if `toolbarData` becomes empty while the drawer is
151+
// open, it never resets this value to the one that would allow drawer section
152+
// to collapse
153+
gridTemplateColumns: 'auto 0 !important',
154+
155+
// template-columns 0 doesn't do anything if the content actually takes space,
156+
// so we override the values to hide the drawer toolbar when there's nothing
157+
// to show
158+
'& > div:nth-child(2)': {
159+
width: '0 !important',
160+
overflow: 'hidden',
161+
},
162+
});
163+
164+
const drawerSectionPortalStyles = css({
165+
minWidth: '100%',
166+
minHeight: '100%',
167+
});
168+
169+
/**
170+
* DrawerAnchor component will render the drawer in any place it is rendered.
171+
* This component has to wrap any content that Drawer will be shown near
172+
*/
173+
export const DrawerAnchor: React.FunctionComponent<{
174+
displayMode?: DrawerDisplayMode;
175+
}> = ({ displayMode, children }) => {
176+
const actions = useContext(DrawerActionsContext);
177+
const drawerSectionItems = useContext(DrawerStateContext);
178+
const prevDrawerSectionItems = useRef<DrawerSectionProps[]>([]);
179+
useEffect(() => {
180+
const prevIds = new Set(
181+
prevDrawerSectionItems.current.map((data) => {
182+
return data.id;
183+
})
184+
);
185+
for (const item of drawerSectionItems) {
186+
if (!prevIds.has(item.id) && item.autoOpen) {
187+
rafraf(() => {
188+
actions.current.openDrawer(item.id);
189+
});
190+
}
191+
}
192+
prevDrawerSectionItems.current = drawerSectionItems;
193+
}, [actions, drawerSectionItems]);
194+
const toolbarData = drawerSectionItems.map((data) => {
195+
return {
196+
...data,
197+
content: (
198+
<div
199+
data-drawer-section={data.id}
200+
className={drawerSectionPortalStyles}
201+
></div>
202+
),
203+
};
204+
});
205+
return (
206+
<DrawerLayout
207+
displayMode={displayMode ?? DrawerDisplayMode.Embedded}
208+
toolbarData={toolbarData}
209+
className={cx(
210+
drawerLayoutFixesStyles,
211+
toolbarData.length === 0 && emptyDrawerLayoutFixesStyles,
212+
// classname is the only property leafygreen passes over to the drawer
213+
// wrapper component that would allow us to target it
214+
'compass-drawer-anchor'
215+
)}
216+
>
217+
<DrawerContextGrabber>{children}</DrawerContextGrabber>
218+
</DrawerLayout>
219+
);
220+
};
221+
222+
/**
223+
* DrawerSection allows to declaratively render sections inside the drawer
224+
* independantly from the Drawer itself
225+
*/
226+
export const DrawerSection: React.FunctionComponent<DrawerSectionProps> = ({
227+
children,
228+
...props
229+
}) => {
230+
const [portalNode, setPortalNode] = useState<Element | null>(null);
231+
const actions = useContext(DrawerActionsContext);
232+
const prevProps = useRef<DrawerSectionProps>();
233+
useEffect(() => {
234+
if (!isEqual(prevProps.current, props)) {
235+
actions.current.updateToolbarData({ autoOpen: false, ...props });
236+
prevProps.current = props;
237+
}
238+
});
239+
useLayoutEffect(() => {
240+
const drawerEl = document.querySelector(
241+
'.compass-drawer-anchor > div:nth-child(2)'
242+
);
243+
if (!drawerEl) {
244+
throw new Error(
245+
'Can not use DrawerSection without DrawerAnchor being mounted on the page'
246+
);
247+
}
248+
setPortalNode(
249+
document.querySelector(`[data-drawer-section="${props.id}"]`)
250+
);
251+
const mutationObserver = new MutationObserver((mutations) => {
252+
for (const mutation of mutations) {
253+
for (const node of Array.from(mutation.addedNodes) as HTMLElement[]) {
254+
if (node.dataset && node.dataset.drawerSection === props.id) {
255+
setPortalNode(node);
256+
}
257+
}
258+
}
259+
});
260+
mutationObserver.observe(drawerEl, {
261+
subtree: true,
262+
childList: true,
263+
});
264+
return () => {
265+
mutationObserver.disconnect();
266+
};
267+
}, [actions, props.id]);
268+
useEffect(() => {
269+
return () => {
270+
actions.current.removeToolbarData(props.id);
271+
};
272+
}, [actions, props.id]);
273+
if (portalNode) {
274+
return ReactDOM.createPortal(children, portalNode);
275+
}
276+
return null;
277+
};
278+
279+
export { DrawerDisplayMode };
280+
281+
export function useDrawerActions() {
282+
const actions = useContext(DrawerActionsContext);
283+
const stableActions = useRef({
284+
openDrawer(id: string) {
285+
actions.current.openDrawer(id);
286+
},
287+
closeDrawer() {
288+
actions.current.closeDrawer();
289+
},
290+
});
291+
return stableActions.current;
292+
}

0 commit comments

Comments
 (0)