|
| 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