소스 검색

feat: sidebar tabs support (#6213)

* feat: Sidebar tabs support [wip]

* tab trigger styling tweaks

* add `:hover` & `:active` states

* replace `@dwelle/tunnel-rat` with `tunnel-rat`

* make stuff more explicit

- remove `Sidebar.Header` fallback (host apps need to render manually), and stop tunneling it (render in place)
- make `docked` state explicit
- stop tunneling `Sidebar.TabTriggers` (render in place)

* redesign sidebar / library as per latest spec

* support no label on `Sidebar.Trigger`

* add Sidebar `props.onStateChange`

* style fixes

* make `appState.isSidebarDocked` into a soft user preference

* px -> rem & refactor

* remove `props.renderSidebar`

* update tests

* remove

* refactor

* rename constants

* tab triggers styling fixes

* factor out library-related logic from generic sidebar trigger

* change `props.onClose` to `onToggle`

* rename `props.value` -> `props.tab`

* add displayNames

* allow HTMLAttributes on applicable compos

* fix example App

* more styling tweaks and fixes

* fix not setting `dockable`

* more style fixes

* fix and align sidebar header button styling

* make DefaultSidebar dockable on if host apps supplies `onDock`

* stop `Sidebar.Trigger` hiding label on mobile

this should be only the default sidebar trigger behavior, and for that we don't need to use `device` hook as we handle in CSS

* fix `dockable` prop of defaultSidebar

* remove extra `typescript` dep

* remove `defaultTab` prop

in favor of explicit `tab` value in `<Sidebar.Trigger/>` and `toggleSidebar()`, to reduce API surface area and solve inconsistency of `appState.openSidebar.tab` not reflecting actual UI value if `defaultTab` was supported (without additional syncing logic which feels like the wrong solution).

* remove `onToggle` in favor of `onStateChange`

reducing API surface area

* fix restore

* comment no longer applies

* reuse `Button` component in sidebar buttons

* fix tests

* split Sidebar sub-components into files

* remove `props.dockable` in favor of `props.onDock` only

* split tests

* fix sidebar showing dock button if no `props.docked` supplied & add more tests

* reorder and group sidebar tests

* clarify

* rename classes & dedupe css

* refactor tests

* update changelog

* update changelog

---------

Co-authored-by: barnabasmolnar <[email protected]>
David Luzar 2 년 전
부모
커밋
e9cae918a7
61개의 변경된 파일1936개의 추가작업 그리고 1395개의 파일을 삭제
  1. 2 2
      package.json
  2. 6 2
      src/appState.ts
  3. 114 115
      src/components/App.tsx
  4. 8 4
      src/components/Button.tsx
  5. 5 2
      src/components/ConfirmDialog.tsx
  6. 144 0
      src/components/DefaultSidebar.test.tsx
  7. 118 0
      src/components/DefaultSidebar.tsx
  8. 1 1
      src/components/Dialog.tsx
  9. 1 1
      src/components/HintViewer.tsx
  10. 70 67
      src/components/LayerUI.tsx
  11. 0 32
      src/components/LibraryButton.scss
  12. 0 57
      src/components/LibraryButton.tsx
  13. 22 48
      src/components/LibraryMenu.scss
  14. 35 177
      src/components/LibraryMenu.tsx
  15. 33 0
      src/components/LibraryMenuControlButtons.tsx
  16. 55 10
      src/components/LibraryMenuHeaderContent.tsx
  17. 2 2
      src/components/LibraryMenuItems.scss
  18. 8 14
      src/components/LibraryMenuItems.tsx
  19. 11 12
      src/components/MobileMenu.tsx
  20. 4 4
      src/components/PasteChartDialog.tsx
  21. 122 81
      src/components/Sidebar/Sidebar.scss
  22. 253 277
      src/components/Sidebar/Sidebar.test.tsx
  23. 218 120
      src/components/Sidebar/Sidebar.tsx
  24. 33 65
      src/components/Sidebar/SidebarHeader.tsx
  25. 18 0
      src/components/Sidebar/SidebarTab.tsx
  26. 26 0
      src/components/Sidebar/SidebarTabTrigger.tsx
  27. 16 0
      src/components/Sidebar/SidebarTabTriggers.tsx
  28. 36 0
      src/components/Sidebar/SidebarTabs.tsx
  29. 34 0
      src/components/Sidebar/SidebarTrigger.scss
  30. 45 0
      src/components/Sidebar/SidebarTrigger.tsx
  31. 26 8
      src/components/Sidebar/common.ts
  32. 0 32
      src/components/context/tunnels.ts
  33. 2 2
      src/components/dropdownMenu/DropdownMenuContent.tsx
  34. 4 4
      src/components/footer/Footer.tsx
  35. 4 4
      src/components/footer/FooterCenter.tsx
  36. 20 6
      src/components/hoc/withInternalFallback.tsx
  37. 0 63
      src/components/hoc/withUpstreamOverride.tsx
  38. 4 4
      src/components/main-menu/MainMenu.tsx
  39. 4 4
      src/components/welcome-screen/WelcomeScreen.Center.tsx
  40. 10 10
      src/components/welcome-screen/WelcomeScreen.Hints.tsx
  41. 7 0
      src/constants.ts
  42. 36 0
      src/context/tunnels.ts
  43. 5 0
      src/context/ui-appState.ts
  44. 1 1
      src/css/styles.scss
  45. 6 0
      src/css/theme.scss
  46. 14 4
      src/css/variables.module.scss
  47. 18 4
      src/data/library.ts
  48. 12 20
      src/data/restore.ts
  49. 2 4
      src/data/types.ts
  50. 1 1
      src/hooks/useOutsideClick.ts
  51. 16 0
      src/packages/excalidraw/CHANGELOG.md
  52. 24 27
      src/packages/excalidraw/example/App.tsx
  53. 4 2
      src/packages/excalidraw/index.tsx
  54. 17 17
      src/tests/__snapshots__/contextmenu.test.tsx.snap
  55. 53 53
      src/tests/__snapshots__/regressionTests.test.tsx.snap
  56. 24 1
      src/tests/data/restore.test.ts
  57. 7 2
      src/tests/library.test.tsx
  58. 1 1
      src/tests/packages/__snapshots__/utils.test.ts.snap
  59. 15 8
      src/types.ts
  60. 17 3
      src/utils.ts
  61. 142 17
      yarn.lock

+ 2 - 2
package.json

@@ -19,7 +19,7 @@
     ]
   },
   "dependencies": {
-    "@dwelle/tunnel-rat": "0.1.1",
+    "@radix-ui/react-tabs": "1.0.2",
     "@sentry/browser": "6.2.5",
     "@sentry/integrations": "6.2.5",
     "@testing-library/jest-dom": "5.16.2",
@@ -51,7 +51,7 @@
     "roughjs": "4.5.2",
     "sass": "1.51.0",
     "socket.io-client": "2.3.1",
-    "tunnel-rat": "0.1.0",
+    "tunnel-rat": "0.1.2",
     "workbox-background-sync": "^6.5.4",
     "workbox-broadcast-update": "^6.5.4",
     "workbox-cacheable-response": "^6.5.4",

+ 6 - 2
src/appState.ts

@@ -58,7 +58,7 @@ export const getDefaultAppState = (): Omit<
     fileHandle: null,
     gridSize: null,
     isBindingEnabled: true,
-    isSidebarDocked: false,
+    defaultSidebarDockedPreference: false,
     isLoading: false,
     isResizing: false,
     isRotating: false,
@@ -150,7 +150,11 @@ const APP_STATE_STORAGE_CONF = (<
   gridSize: { browser: true, export: true, server: true },
   height: { browser: false, export: false, server: false },
   isBindingEnabled: { browser: false, export: false, server: false },
-  isSidebarDocked: { browser: true, export: false, server: false },
+  defaultSidebarDockedPreference: {
+    browser: true,
+    export: false,
+    server: false,
+  },
   isLoading: { browser: false, export: false, server: false },
   isResizing: { browser: false, export: false, server: false },
   isRotating: { browser: false, export: false, server: false },

+ 114 - 115
src/components/App.tsx

@@ -210,6 +210,8 @@ import {
   PointerDownState,
   SceneData,
   Device,
+  SidebarName,
+  SidebarTabName,
 } from "../types";
 import {
   debounce,
@@ -299,6 +301,9 @@ import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
 import { actionWrapTextInContainer } from "../actions/actionBoundText";
 import BraveMeasureTextError from "./BraveMeasureTextError";
 
+const AppContext = React.createContext<AppClassProperties>(null!);
+const AppPropsContext = React.createContext<AppProps>(null!);
+
 const deviceContextInitialValue = {
   isSmScreen: false,
   isMobile: false,
@@ -340,6 +345,8 @@ const ExcalidrawActionManagerContext = React.createContext<ActionManager>(
 );
 ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext";
 
+export const useApp = () => useContext(AppContext);
+export const useAppProps = () => useContext(AppPropsContext);
 export const useDevice = () => useContext<Device>(DeviceContext);
 export const useExcalidrawContainer = () =>
   useContext(ExcalidrawContainerContext);
@@ -400,7 +407,7 @@ class App extends React.Component<AppProps, AppState> {
   private nearestScrollableContainer: HTMLElement | Document | undefined;
   public library: AppClassProperties["library"];
   public libraryItemsFromStorage: LibraryItems | undefined;
-  private id: string;
+  public id: string;
   private history: History;
   private excalidrawContainerValue: {
     container: HTMLDivElement | null;
@@ -438,7 +445,7 @@ class App extends React.Component<AppProps, AppState> {
       width: window.innerWidth,
       height: window.innerHeight,
       showHyperlinkPopup: false,
-      isSidebarDocked: false,
+      defaultSidebarDockedPreference: false,
     };
 
     this.id = nanoid();
@@ -469,7 +476,7 @@ class App extends React.Component<AppProps, AppState> {
         setActiveTool: this.setActiveTool,
         setCursor: this.setCursor,
         resetCursor: this.resetCursor,
-        toggleMenu: this.toggleMenu,
+        toggleSidebar: this.toggleSidebar,
       } as const;
       if (typeof excalidrawRef === "function") {
         excalidrawRef(api);
@@ -577,101 +584,91 @@ class App extends React.Component<AppProps, AppState> {
           this.props.handleKeyboardGlobally ? undefined : this.onKeyDown
         }
       >
-        <ExcalidrawContainerContext.Provider
-          value={this.excalidrawContainerValue}
-        >
-          <DeviceContext.Provider value={this.device}>
-            <ExcalidrawSetAppStateContext.Provider value={this.setAppState}>
-              <ExcalidrawAppStateContext.Provider value={this.state}>
-                <ExcalidrawElementsContext.Provider
-                  value={this.scene.getNonDeletedElements()}
-                >
-                  <ExcalidrawActionManagerContext.Provider
-                    value={this.actionManager}
-                  >
-                    <LayerUI
-                      canvas={this.canvas}
-                      appState={this.state}
-                      files={this.files}
-                      setAppState={this.setAppState}
-                      actionManager={this.actionManager}
-                      elements={this.scene.getNonDeletedElements()}
-                      onLockToggle={this.toggleLock}
-                      onPenModeToggle={this.togglePenMode}
-                      onHandToolToggle={this.onHandToolToggle}
-                      onInsertElements={(elements) =>
-                        this.addElementsFromPasteOrLibrary({
-                          elements,
-                          position: "center",
-                          files: null,
-                        })
-                      }
-                      langCode={getLanguage().code}
-                      renderTopRightUI={renderTopRightUI}
-                      renderCustomStats={renderCustomStats}
-                      renderCustomSidebar={this.props.renderSidebar}
-                      showExitZenModeBtn={
-                        typeof this.props?.zenModeEnabled === "undefined" &&
-                        this.state.zenModeEnabled
-                      }
-                      libraryReturnUrl={this.props.libraryReturnUrl}
-                      UIOptions={this.props.UIOptions}
-                      focusContainer={this.focusContainer}
-                      library={this.library}
-                      id={this.id}
-                      onImageAction={this.onImageAction}
-                      renderWelcomeScreen={
-                        !this.state.isLoading &&
-                        this.state.showWelcomeScreen &&
-                        this.state.activeTool.type === "selection" &&
-                        !this.scene.getElementsIncludingDeleted().length
-                      }
+        <AppContext.Provider value={this}>
+          <AppPropsContext.Provider value={this.props}>
+            <ExcalidrawContainerContext.Provider
+              value={this.excalidrawContainerValue}
+            >
+              <DeviceContext.Provider value={this.device}>
+                <ExcalidrawSetAppStateContext.Provider value={this.setAppState}>
+                  <ExcalidrawAppStateContext.Provider value={this.state}>
+                    <ExcalidrawElementsContext.Provider
+                      value={this.scene.getNonDeletedElements()}
                     >
-                      {this.props.children}
-                    </LayerUI>
-                    <div className="excalidraw-textEditorContainer" />
-                    <div className="excalidraw-contextMenuContainer" />
-                    {selectedElement.length === 1 &&
-                      !this.state.contextMenu &&
-                      this.state.showHyperlinkPopup && (
-                        <Hyperlink
-                          key={selectedElement[0].id}
-                          element={selectedElement[0]}
+                      <ExcalidrawActionManagerContext.Provider
+                        value={this.actionManager}
+                      >
+                        <LayerUI
+                          canvas={this.canvas}
+                          appState={this.state}
+                          files={this.files}
                           setAppState={this.setAppState}
-                          onLinkOpen={this.props.onLinkOpen}
-                        />
-                      )}
-                    {this.state.toast !== null && (
-                      <Toast
-                        message={this.state.toast.message}
-                        onClose={() => this.setToast(null)}
-                        duration={this.state.toast.duration}
-                        closable={this.state.toast.closable}
-                      />
-                    )}
-                    {this.state.contextMenu && (
-                      <ContextMenu
-                        items={this.state.contextMenu.items}
-                        top={this.state.contextMenu.top}
-                        left={this.state.contextMenu.left}
-                        actionManager={this.actionManager}
-                      />
-                    )}
-                    <main>{this.renderCanvas()}</main>
-                  </ExcalidrawActionManagerContext.Provider>
-                </ExcalidrawElementsContext.Provider>{" "}
-              </ExcalidrawAppStateContext.Provider>
-            </ExcalidrawSetAppStateContext.Provider>
-          </DeviceContext.Provider>
-        </ExcalidrawContainerContext.Provider>
+                          actionManager={this.actionManager}
+                          elements={this.scene.getNonDeletedElements()}
+                          onLockToggle={this.toggleLock}
+                          onPenModeToggle={this.togglePenMode}
+                          onHandToolToggle={this.onHandToolToggle}
+                          langCode={getLanguage().code}
+                          renderTopRightUI={renderTopRightUI}
+                          renderCustomStats={renderCustomStats}
+                          showExitZenModeBtn={
+                            typeof this.props?.zenModeEnabled === "undefined" &&
+                            this.state.zenModeEnabled
+                          }
+                          UIOptions={this.props.UIOptions}
+                          onImageAction={this.onImageAction}
+                          renderWelcomeScreen={
+                            !this.state.isLoading &&
+                            this.state.showWelcomeScreen &&
+                            this.state.activeTool.type === "selection" &&
+                            !this.scene.getElementsIncludingDeleted().length
+                          }
+                        >
+                          {this.props.children}
+                        </LayerUI>
+                        <div className="excalidraw-textEditorContainer" />
+                        <div className="excalidraw-contextMenuContainer" />
+                        {selectedElement.length === 1 &&
+                          !this.state.contextMenu &&
+                          this.state.showHyperlinkPopup && (
+                            <Hyperlink
+                              key={selectedElement[0].id}
+                              element={selectedElement[0]}
+                              setAppState={this.setAppState}
+                              onLinkOpen={this.props.onLinkOpen}
+                            />
+                          )}
+                        {this.state.toast !== null && (
+                          <Toast
+                            message={this.state.toast.message}
+                            onClose={() => this.setToast(null)}
+                            duration={this.state.toast.duration}
+                            closable={this.state.toast.closable}
+                          />
+                        )}
+                        {this.state.contextMenu && (
+                          <ContextMenu
+                            items={this.state.contextMenu.items}
+                            top={this.state.contextMenu.top}
+                            left={this.state.contextMenu.left}
+                            actionManager={this.actionManager}
+                          />
+                        )}
+                        <main>{this.renderCanvas()}</main>
+                      </ExcalidrawActionManagerContext.Provider>
+                    </ExcalidrawElementsContext.Provider>{" "}
+                  </ExcalidrawAppStateContext.Provider>
+                </ExcalidrawSetAppStateContext.Provider>
+              </DeviceContext.Provider>
+            </ExcalidrawContainerContext.Provider>
+          </AppPropsContext.Provider>
+        </AppContext.Provider>
       </div>
     );
   }
 
   public focusContainer: AppClassProperties["focusContainer"] = () => {
-    if (this.props.autoFocus) {
-      this.excalidrawContainerRef.current?.focus();
-    }
+    this.excalidrawContainerRef.current?.focus();
   };
 
   public getSceneElementsIncludingDeleted = () => {
@@ -682,6 +679,14 @@ class App extends React.Component<AppProps, AppState> {
     return this.scene.getNonDeletedElements();
   };
 
+  public onInsertElements = (elements: readonly ExcalidrawElement[]) => {
+    this.addElementsFromPasteOrLibrary({
+      elements,
+      position: "center",
+      files: null,
+    });
+  };
+
   private syncActionResult = withBatchedUpdates(
     (actionResult: ActionResult) => {
       if (this.unmounted || actionResult === false) {
@@ -951,7 +956,7 @@ class App extends React.Component<AppProps, AppState> {
     this.scene.addCallback(this.onSceneUpdated);
     this.addEventListeners();
 
-    if (this.excalidrawContainerRef.current) {
+    if (this.props.autoFocus && this.excalidrawContainerRef.current) {
       this.focusContainer();
     }
 
@@ -1679,7 +1684,7 @@ class App extends React.Component<AppProps, AppState> {
           openSidebar:
             this.state.openSidebar &&
             this.device.canDeviceFitSidebar &&
-            this.state.isSidebarDocked
+            this.state.defaultSidebarDockedPreference
               ? this.state.openSidebar
               : null,
           selectedElementIds: newElements.reduce(
@@ -2017,30 +2022,24 @@ class App extends React.Component<AppProps, AppState> {
   /**
    * @returns whether the menu was toggled on or off
    */
-  public toggleMenu = (
-    type: "library" | "customSidebar",
-    force?: boolean,
-  ): boolean => {
-    if (type === "customSidebar" && !this.props.renderSidebar) {
-      console.warn(
-        `attempting to toggle "customSidebar", but no "props.renderSidebar" is defined`,
-      );
-      return false;
-    }
-
-    if (type === "library" || type === "customSidebar") {
-      let nextValue;
-      if (force === undefined) {
-        nextValue = this.state.openSidebar === type ? null : type;
-      } else {
-        nextValue = force ? type : null;
-      }
-      this.setState({ openSidebar: nextValue });
-
-      return !!nextValue;
+  public toggleSidebar = ({
+    name,
+    tab,
+    force,
+  }: {
+    name: SidebarName;
+    tab?: SidebarTabName;
+    force?: boolean;
+  }): boolean => {
+    let nextName;
+    if (force === undefined) {
+      nextName = this.state.openSidebar?.name === name ? null : name;
+    } else {
+      nextName = force ? name : null;
     }
+    this.setState({ openSidebar: nextName ? { name: nextName, tab } : null });
 
-    return false;
+    return !!nextName;
   };
 
   private updateCurrentCursorPosition = withBatchedUpdates(

+ 8 - 4
src/components/Button.tsx

@@ -1,8 +1,12 @@
+import clsx from "clsx";
+import { composeEventHandlers } from "../utils";
 import "./Button.scss";
 
 interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
   type?: "button" | "submit" | "reset";
   onSelect: () => any;
+  /** whether button is in active state */
+  selected?: boolean;
   children: React.ReactNode;
   className?: string;
 }
@@ -15,18 +19,18 @@ interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
 export const Button = ({
   type = "button",
   onSelect,
+  selected,
   children,
   className = "",
   ...rest
 }: ButtonProps) => {
   return (
     <button
-      onClick={(event) => {
+      onClick={composeEventHandlers(rest.onClick, (event) => {
         onSelect();
-        rest.onClick?.(event);
-      }}
+      })}
       type={type}
-      className={`excalidraw-button ${className}`}
+      className={clsx("excalidraw-button", className, { selected })}
       {...rest}
     >
       {children}

+ 5 - 2
src/components/ConfirmDialog.tsx

@@ -4,8 +4,8 @@ import { Dialog, DialogProps } from "./Dialog";
 import "./ConfirmDialog.scss";
 import DialogActionButton from "./DialogActionButton";
 import { useSetAtom } from "jotai";
-import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
-import { useExcalidrawSetAppState } from "./App";
+import { isLibraryMenuOpenAtom } from "./LibraryMenu";
+import { useExcalidrawContainer, useExcalidrawSetAppState } from "./App";
 import { jotaiScope } from "../jotai";
 
 interface Props extends Omit<DialogProps, "onCloseRequest"> {
@@ -26,6 +26,7 @@ const ConfirmDialog = (props: Props) => {
   } = props;
   const setAppState = useExcalidrawSetAppState();
   const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom, jotaiScope);
+  const { container } = useExcalidrawContainer();
 
   return (
     <Dialog
@@ -42,6 +43,7 @@ const ConfirmDialog = (props: Props) => {
             setAppState({ openMenu: null });
             setIsLibraryMenuOpen(false);
             onCancel();
+            container?.focus();
           }}
         />
         <DialogActionButton
@@ -50,6 +52,7 @@ const ConfirmDialog = (props: Props) => {
             setAppState({ openMenu: null });
             setIsLibraryMenuOpen(false);
             onConfirm();
+            container?.focus();
           }}
           actionType="danger"
         />

+ 144 - 0
src/components/DefaultSidebar.test.tsx

@@ -0,0 +1,144 @@
+import React from "react";
+import { DEFAULT_SIDEBAR } from "../constants";
+import { DefaultSidebar } from "../packages/excalidraw/index";
+import {
+  fireEvent,
+  waitFor,
+  withExcalidrawDimensions,
+} from "../tests/test-utils";
+import {
+  assertExcalidrawWithSidebar,
+  assertSidebarDockButton,
+} from "./Sidebar/Sidebar.test";
+
+const { h } = window;
+
+describe("DefaultSidebar", () => {
+  it("when `docked={undefined}` & `onDock={undefined}`, should allow docking", async () => {
+    await assertExcalidrawWithSidebar(
+      <DefaultSidebar />,
+      DEFAULT_SIDEBAR.name,
+      async () => {
+        expect(h.state.defaultSidebarDockedPreference).toBe(false);
+
+        const { dockButton } = await assertSidebarDockButton(true);
+
+        fireEvent.click(dockButton);
+        await waitFor(() => {
+          expect(h.state.defaultSidebarDockedPreference).toBe(true);
+          expect(dockButton).toHaveClass("selected");
+        });
+
+        fireEvent.click(dockButton);
+        await waitFor(() => {
+          expect(h.state.defaultSidebarDockedPreference).toBe(false);
+          expect(dockButton).not.toHaveClass("selected");
+        });
+      },
+    );
+  });
+
+  it("when `docked={undefined}` & `onDock`, should allow docking", async () => {
+    await assertExcalidrawWithSidebar(
+      <DefaultSidebar onDock={() => {}} />,
+      DEFAULT_SIDEBAR.name,
+      async () => {
+        expect(h.state.defaultSidebarDockedPreference).toBe(false);
+
+        const { dockButton } = await assertSidebarDockButton(true);
+
+        fireEvent.click(dockButton);
+        await waitFor(() => {
+          expect(h.state.defaultSidebarDockedPreference).toBe(true);
+          expect(dockButton).toHaveClass("selected");
+        });
+
+        fireEvent.click(dockButton);
+        await waitFor(() => {
+          expect(h.state.defaultSidebarDockedPreference).toBe(false);
+          expect(dockButton).not.toHaveClass("selected");
+        });
+      },
+    );
+  });
+
+  it("when `docked={true}` & `onDock`, should allow docking", async () => {
+    await assertExcalidrawWithSidebar(
+      <DefaultSidebar onDock={() => {}} />,
+      DEFAULT_SIDEBAR.name,
+      async () => {
+        expect(h.state.defaultSidebarDockedPreference).toBe(false);
+
+        const { dockButton } = await assertSidebarDockButton(true);
+
+        fireEvent.click(dockButton);
+        await waitFor(() => {
+          expect(h.state.defaultSidebarDockedPreference).toBe(true);
+          expect(dockButton).toHaveClass("selected");
+        });
+
+        fireEvent.click(dockButton);
+        await waitFor(() => {
+          expect(h.state.defaultSidebarDockedPreference).toBe(false);
+          expect(dockButton).not.toHaveClass("selected");
+        });
+      },
+    );
+  });
+
+  it("when `onDock={false}`, should disable docking", async () => {
+    await assertExcalidrawWithSidebar(
+      <DefaultSidebar onDock={false} />,
+      DEFAULT_SIDEBAR.name,
+      async () => {
+        await withExcalidrawDimensions(
+          { width: 1920, height: 1080 },
+          async () => {
+            expect(h.state.defaultSidebarDockedPreference).toBe(false);
+
+            await assertSidebarDockButton(false);
+          },
+        );
+      },
+    );
+  });
+
+  it("when `docked={true}` & `onDock={false}`, should force-dock sidebar", async () => {
+    await assertExcalidrawWithSidebar(
+      <DefaultSidebar docked onDock={false} />,
+      DEFAULT_SIDEBAR.name,
+      async () => {
+        expect(h.state.defaultSidebarDockedPreference).toBe(false);
+
+        const { sidebar } = await assertSidebarDockButton(false);
+        expect(sidebar).toHaveClass("sidebar--docked");
+      },
+    );
+  });
+
+  it("when `docked={true}` & `onDock={undefined}`, should force-dock sidebar", async () => {
+    await assertExcalidrawWithSidebar(
+      <DefaultSidebar docked />,
+      DEFAULT_SIDEBAR.name,
+      async () => {
+        expect(h.state.defaultSidebarDockedPreference).toBe(false);
+
+        const { sidebar } = await assertSidebarDockButton(false);
+        expect(sidebar).toHaveClass("sidebar--docked");
+      },
+    );
+  });
+
+  it("when `docked={false}` & `onDock={undefined}`, should force-undock sidebar", async () => {
+    await assertExcalidrawWithSidebar(
+      <DefaultSidebar docked={false} />,
+      DEFAULT_SIDEBAR.name,
+      async () => {
+        expect(h.state.defaultSidebarDockedPreference).toBe(false);
+
+        const { sidebar } = await assertSidebarDockButton(false);
+        expect(sidebar).not.toHaveClass("sidebar--docked");
+      },
+    );
+  });
+});

+ 118 - 0
src/components/DefaultSidebar.tsx

@@ -0,0 +1,118 @@
+import clsx from "clsx";
+import { DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_TAB } from "../constants";
+import { useTunnels } from "../context/tunnels";
+import { useUIAppState } from "../context/ui-appState";
+import { t } from "../i18n";
+import { MarkOptional, Merge } from "../utility-types";
+import { composeEventHandlers } from "../utils";
+import { useExcalidrawSetAppState } from "./App";
+import { withInternalFallback } from "./hoc/withInternalFallback";
+import { LibraryMenu } from "./LibraryMenu";
+import { SidebarProps, SidebarTriggerProps } from "./Sidebar/common";
+import { Sidebar } from "./Sidebar/Sidebar";
+
+const DefaultSidebarTrigger = withInternalFallback(
+  "DefaultSidebarTrigger",
+  (
+    props: Omit<SidebarTriggerProps, "name"> &
+      React.HTMLAttributes<HTMLDivElement>,
+  ) => {
+    const { DefaultSidebarTriggerTunnel } = useTunnels();
+    return (
+      <DefaultSidebarTriggerTunnel.In>
+        <Sidebar.Trigger
+          {...props}
+          className="default-sidebar-trigger"
+          name={DEFAULT_SIDEBAR.name}
+        />
+      </DefaultSidebarTriggerTunnel.In>
+    );
+  },
+);
+DefaultSidebarTrigger.displayName = "DefaultSidebarTrigger";
+
+const DefaultTabTriggers = ({
+  children,
+  ...rest
+}: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
+  const { DefaultSidebarTabTriggersTunnel } = useTunnels();
+  return (
+    <DefaultSidebarTabTriggersTunnel.In>
+      <Sidebar.TabTriggers {...rest}>{children}</Sidebar.TabTriggers>
+    </DefaultSidebarTabTriggersTunnel.In>
+  );
+};
+DefaultTabTriggers.displayName = "DefaultTabTriggers";
+
+export const DefaultSidebar = Object.assign(
+  withInternalFallback(
+    "DefaultSidebar",
+    ({
+      children,
+      className,
+      onDock,
+      docked,
+      ...rest
+    }: Merge<
+      MarkOptional<Omit<SidebarProps, "name">, "children">,
+      {
+        /** pass `false` to disable docking */
+        onDock?: SidebarProps["onDock"] | false;
+      }
+    >) => {
+      const appState = useUIAppState();
+      const setAppState = useExcalidrawSetAppState();
+
+      const { DefaultSidebarTabTriggersTunnel } = useTunnels();
+
+      return (
+        <Sidebar
+          {...rest}
+          name="default"
+          key="default"
+          className={clsx("default-sidebar", className)}
+          docked={docked ?? appState.defaultSidebarDockedPreference}
+          onDock={
+            // `onDock=false` disables docking.
+            // if `docked` passed, but no onDock passed, disable manual docking.
+            onDock === false || (!onDock && docked != null)
+              ? undefined
+              : // compose to allow the host app to listen on default behavior
+                composeEventHandlers(onDock, (docked) => {
+                  setAppState({ defaultSidebarDockedPreference: docked });
+                })
+          }
+        >
+          <Sidebar.Tabs>
+            <Sidebar.Header>
+              {rest.__fallback && (
+                <div
+                  style={{
+                    color: "var(--color-primary)",
+                    fontSize: "1.2em",
+                    fontWeight: "bold",
+                    textOverflow: "ellipsis",
+                    overflow: "hidden",
+                    whiteSpace: "nowrap",
+                    paddingRight: "1em",
+                  }}
+                >
+                  {t("toolBar.library")}
+                </div>
+              )}
+              <DefaultSidebarTabTriggersTunnel.Out />
+            </Sidebar.Header>
+            <Sidebar.Tab tab={LIBRARY_SIDEBAR_TAB}>
+              <LibraryMenu />
+            </Sidebar.Tab>
+            {children}
+          </Sidebar.Tabs>
+        </Sidebar>
+      );
+    },
+  ),
+  {
+    Trigger: DefaultSidebarTrigger,
+    TabTriggers: DefaultTabTriggers,
+  },
+);

+ 1 - 1
src/components/Dialog.tsx

@@ -15,7 +15,7 @@ import { Modal } from "./Modal";
 import { AppState } from "../types";
 import { queryFocusableElements } from "../utils";
 import { useSetAtom } from "jotai";
-import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
+import { isLibraryMenuOpenAtom } from "./LibraryMenu";
 import { jotaiScope } from "../jotai";
 
 export interface DialogProps {

+ 1 - 1
src/components/HintViewer.tsx

@@ -29,7 +29,7 @@ const getHints = ({
   const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
   const multiMode = appState.multiElement !== null;
 
-  if (appState.openSidebar === "library" && !device.canDeviceFitSidebar) {
+  if (appState.openSidebar && !device.canDeviceFitSidebar) {
     return null;
   }
 

+ 70 - 67
src/components/LayerUI.tsx

@@ -1,7 +1,7 @@
 import clsx from "clsx";
 import React from "react";
 import { ActionManager } from "../actions/manager";
-import { CLASSES, LIBRARY_SIDEBAR_WIDTH } from "../constants";
+import { CLASSES, DEFAULT_SIDEBAR, LIBRARY_SIDEBAR_WIDTH } from "../constants";
 import { exportCanvas } from "../data";
 import { isTextElement, showSelectedShapeActions } from "../element";
 import { NonDeletedExcalidrawElement } from "../element/types";
@@ -9,7 +9,7 @@ import { Language, t } from "../i18n";
 import { calculateScrollCenter } from "../scene";
 import { ExportType } from "../scene/types";
 import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
-import { isShallowEqual, muteFSAbortError } from "../utils";
+import { capitalizeString, isShallowEqual, muteFSAbortError } from "../utils";
 import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
 import { ErrorDialog } from "./ErrorDialog";
 import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
@@ -24,28 +24,28 @@ import { Section } from "./Section";
 import { HelpDialog } from "./HelpDialog";
 import Stack from "./Stack";
 import { UserList } from "./UserList";
-import Library from "../data/library";
 import { JSONExportDialog } from "./JSONExportDialog";
-import { LibraryButton } from "./LibraryButton";
 import { isImageFileHandle } from "../data/blob";
-import { LibraryMenu } from "./LibraryMenu";
-
-import "./LayerUI.scss";
-import "./Toolbar.scss";
 import { PenModeButton } from "./PenModeButton";
 import { trackEvent } from "../analytics";
 import { useDevice } from "../components/App";
 import { Stats } from "./Stats";
 import { actionToggleStats } from "../actions/actionToggleStats";
 import Footer from "./footer/Footer";
-import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
+import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
 import { jotaiScope } from "../jotai";
-import { Provider, useAtom } from "jotai";
+import { Provider, useAtomValue } from "jotai";
 import MainMenu from "./main-menu/MainMenu";
 import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
 import { HandButton } from "./HandButton";
 import { isHandToolActive } from "../appState";
-import { TunnelsContext, useInitializeTunnels } from "./context/tunnels";
+import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
+import { LibraryIcon } from "./icons";
+import { UIAppStateContext } from "../context/ui-appState";
+import { DefaultSidebar } from "./DefaultSidebar";
+
+import "./LayerUI.scss";
+import "./Toolbar.scss";
 
 interface LayerUIProps {
   actionManager: ActionManager;
@@ -57,17 +57,11 @@ interface LayerUIProps {
   onLockToggle: () => void;
   onHandToolToggle: () => void;
   onPenModeToggle: () => void;
-  onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
   showExitZenModeBtn: boolean;
   langCode: Language["code"];
   renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
   renderCustomStats?: ExcalidrawProps["renderCustomStats"];
-  renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
-  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
   UIOptions: AppProps["UIOptions"];
-  focusContainer: () => void;
-  library: Library;
-  id: string;
   onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
   renderWelcomeScreen: boolean;
   children?: React.ReactNode;
@@ -109,16 +103,10 @@ const LayerUI = ({
   onLockToggle,
   onHandToolToggle,
   onPenModeToggle,
-  onInsertElements,
   showExitZenModeBtn,
   renderTopRightUI,
   renderCustomStats,
-  renderCustomSidebar,
-  libraryReturnUrl,
   UIOptions,
-  focusContainer,
-  library,
-  id,
   onImageAction,
   renderWelcomeScreen,
   children,
@@ -197,8 +185,8 @@ const LayerUI = ({
     <div style={{ position: "relative" }}>
       {/* wrapping to Fragment stops React from occasionally complaining
                 about identical Keys */}
-      <tunnels.mainMenuTunnel.Out />
-      {renderWelcomeScreen && <tunnels.welcomeScreenMenuHintTunnel.Out />}
+      <tunnels.MainMenuTunnel.Out />
+      {renderWelcomeScreen && <tunnels.WelcomeScreenMenuHintTunnel.Out />}
     </div>
   );
 
@@ -250,7 +238,7 @@ const LayerUI = ({
               {(heading: React.ReactNode) => (
                 <div style={{ position: "relative" }}>
                   {renderWelcomeScreen && (
-                    <tunnels.welcomeScreenToolbarHintTunnel.Out />
+                    <tunnels.WelcomeScreenToolbarHintTunnel.Out />
                   )}
                   <Stack.Col gap={4} align="start">
                     <Stack.Row
@@ -324,9 +312,12 @@ const LayerUI = ({
           >
             <UserList collaborators={appState.collaborators} />
             {renderTopRightUI?.(device.isMobile, appState)}
-            {!appState.viewModeEnabled && (
-              <LibraryButton appState={appState} setAppState={setAppState} />
-            )}
+            {!appState.viewModeEnabled &&
+              // hide button when sidebar docked
+              (!isSidebarDocked ||
+                appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
+                <tunnels.DefaultSidebarTriggerTunnel.Out />
+              )}
           </div>
         </div>
       </FixedSideContainer>
@@ -334,21 +325,21 @@ const LayerUI = ({
   };
 
   const renderSidebars = () => {
-    return appState.openSidebar === "customSidebar" ? (
-      renderCustomSidebar?.() || null
-    ) : appState.openSidebar === "library" ? (
-      <LibraryMenu
-        appState={appState}
-        onInsertElements={onInsertElements}
-        libraryReturnUrl={libraryReturnUrl}
-        focusContainer={focusContainer}
-        library={library}
-        id={id}
+    return (
+      <DefaultSidebar
+        __fallback
+        onDock={(docked) => {
+          trackEvent(
+            "sidebar",
+            `toggleDock (${docked ? "dock" : "undock"})`,
+            `(${device.isMobile ? "mobile" : "desktop"})`,
+          );
+        }}
       />
-    ) : null;
+    );
   };
 
-  const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope);
+  const isSidebarDocked = useAtomValue(isSidebarDockedAtom, jotaiScope);
 
   const layerUIJSX = (
     <>
@@ -358,8 +349,25 @@ const LayerUI = ({
       {children}
       {/* render component fallbacks. Can be rendered anywhere as they'll be
           tunneled away. We only render tunneled components that actually
-          have defaults when host do not render anything. */}
+        have defaults when host do not render anything. */}
       <DefaultMainMenu UIOptions={UIOptions} />
+      <DefaultSidebar.Trigger
+        __fallback
+        icon={LibraryIcon}
+        title={capitalizeString(t("toolBar.library"))}
+        onToggle={(open) => {
+          if (open) {
+            trackEvent(
+              "sidebar",
+              `${DEFAULT_SIDEBAR.name} (open)`,
+              `button (${device.isMobile ? "mobile" : "desktop"})`,
+            );
+          }
+        }}
+        tab={DEFAULT_SIDEBAR.defaultTab}
+      >
+        {t("toolBar.library")}
+      </DefaultSidebar.Trigger>
       {/* ------------------------------------------------------------------ */}
 
       {appState.isLoading && <LoadingMessage delay={250} />}
@@ -382,7 +390,6 @@ const LayerUI = ({
         <PasteChartDialog
           setAppState={setAppState}
           appState={appState}
-          onInsertChart={onInsertElements}
           onClose={() =>
             setAppState({
               pasteDialog: { shown: false, data: null },
@@ -410,7 +417,6 @@ const LayerUI = ({
           renderWelcomeScreen={renderWelcomeScreen}
         />
       )}
-
       {!device.isMobile && (
         <>
           <div
@@ -422,15 +428,14 @@ const LayerUI = ({
                   !isTextElement(appState.editingElement)),
             })}
             style={
-              ((appState.openSidebar === "library" &&
-                appState.isSidebarDocked) ||
-                hostSidebarCounters.docked) &&
+              appState.openSidebar &&
+              isSidebarDocked &&
               device.canDeviceFitSidebar
                 ? { width: `calc(100% - ${LIBRARY_SIDEBAR_WIDTH}px)` }
                 : {}
             }
           >
-            {renderWelcomeScreen && <tunnels.welcomeScreenCenterTunnel.Out />}
+            {renderWelcomeScreen && <tunnels.WelcomeScreenCenterTunnel.Out />}
             {renderFixedSideContainer()}
             <Footer
               appState={appState}
@@ -469,17 +474,22 @@ const LayerUI = ({
   );
 
   return (
-    <Provider scope={tunnels.jotaiScope}>
-      <TunnelsContext.Provider value={tunnels}>
-        {layerUIJSX}
-      </TunnelsContext.Provider>
-    </Provider>
+    <UIAppStateContext.Provider value={appState}>
+      <Provider scope={tunnels.jotaiScope}>
+        <TunnelsContext.Provider value={tunnels}>
+          {layerUIJSX}
+        </TunnelsContext.Provider>
+      </Provider>
+    </UIAppStateContext.Provider>
   );
 };
 
 const stripIrrelevantAppStateProps = (
   appState: AppState,
-): Partial<AppState> => {
+): Omit<
+  AppState,
+  "suggestedBindings" | "startBoundElement" | "cursorButton"
+> => {
   const { suggestedBindings, startBoundElement, cursorButton, ...ret } =
     appState;
   return ret;
@@ -491,24 +501,17 @@ const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
     return false;
   }
 
-  const {
-    canvas: _prevCanvas,
-    // not stable, but shouldn't matter in our case
-    onInsertElements: _prevOnInsertElements,
-    appState: prevAppState,
-    ...prev
-  } = prevProps;
-  const {
-    canvas: _nextCanvas,
-    onInsertElements: _nextOnInsertElements,
-    appState: nextAppState,
-    ...next
-  } = nextProps;
+  const { canvas: _prevCanvas, appState: prevAppState, ...prev } = prevProps;
+  const { canvas: _nextCanvas, appState: nextAppState, ...next } = nextProps;
 
   return (
     isShallowEqual(
       stripIrrelevantAppStateProps(prevAppState),
       stripIrrelevantAppStateProps(nextAppState),
+      {
+        selectedElementIds: isShallowEqual,
+        selectedGroupIds: isShallowEqual,
+      },
     ) && isShallowEqual(prev, next)
   );
 };

+ 0 - 32
src/components/LibraryButton.scss

@@ -1,32 +0,0 @@
-@import "../css/variables.module";
-
-.library-button {
-  @include outlineButtonStyles;
-
-  background-color: var(--island-bg-color);
-
-  width: auto;
-  height: var(--lg-button-size);
-
-  display: flex;
-  align-items: center;
-  gap: 0.5rem;
-
-  line-height: 0;
-
-  font-size: 0.75rem;
-  letter-spacing: 0.4px;
-
-  svg {
-    width: var(--lg-icon-size);
-    height: var(--lg-icon-size);
-  }
-
-  &__label {
-    display: none;
-
-    @media screen and (min-width: 1024px) {
-      display: block;
-    }
-  }
-}

+ 0 - 57
src/components/LibraryButton.tsx

@@ -1,57 +0,0 @@
-import React from "react";
-import { t } from "../i18n";
-import { AppState } from "../types";
-import { capitalizeString } from "../utils";
-import { trackEvent } from "../analytics";
-import { useDevice } from "./App";
-import "./LibraryButton.scss";
-import { LibraryIcon } from "./icons";
-
-export const LibraryButton: React.FC<{
-  appState: AppState;
-  setAppState: React.Component<any, AppState>["setState"];
-  isMobile?: boolean;
-}> = ({ appState, setAppState, isMobile }) => {
-  const device = useDevice();
-  const showLabel = !isMobile;
-
-  // TODO barnabasmolnar/redesign
-  // not great, toolbar jumps in a jarring manner
-  if (appState.isSidebarDocked && appState.openSidebar === "library") {
-    return null;
-  }
-
-  return (
-    <label title={`${capitalizeString(t("toolBar.library"))}`}>
-      <input
-        className="ToolIcon_type_checkbox"
-        type="checkbox"
-        name="editor-library"
-        onChange={(event) => {
-          document
-            .querySelector(".layer-ui__wrapper")
-            ?.classList.remove("animate");
-          const isOpen = event.target.checked;
-          setAppState({ openSidebar: isOpen ? "library" : null });
-          // track only openings
-          if (isOpen) {
-            trackEvent(
-              "library",
-              "toggleLibrary (open)",
-              `toolbar (${device.isMobile ? "mobile" : "desktop"})`,
-            );
-          }
-        }}
-        checked={appState.openSidebar === "library"}
-        aria-label={capitalizeString(t("toolBar.library"))}
-        aria-keyshortcuts="0"
-      />
-      <div className="library-button">
-        <div>{LibraryIcon}</div>
-        {showLabel && (
-          <div className="library-button__label">{t("toolBar.library")}</div>
-        )}
-      </div>
-    </label>
-  );
-};

+ 22 - 48
src/components/LibraryMenu.scss

@@ -1,9 +1,9 @@
 @import "open-color/open-color";
 
 .excalidraw {
-  .layer-ui__library-sidebar {
-    display: flex;
-    flex-direction: column;
+  .library-menu-items-container {
+    height: 100%;
+    width: 100%;
   }
 
   .layer-ui__library {
@@ -11,28 +11,6 @@
     flex-direction: column;
 
     flex: 1 1 auto;
-
-    .layer-ui__library-header {
-      display: flex;
-      align-items: center;
-      width: 100%;
-      margin: 2px 0 15px 0;
-      .Spinner {
-        margin-right: 1rem;
-      }
-
-      button {
-        // 2px from the left to account for focus border of left-most button
-        margin: 0 2px;
-      }
-    }
-  }
-
-  .layer-ui__sidebar {
-    .library-menu-items-container {
-      height: 100%;
-      width: 100%;
-    }
   }
 
   .library-actions-counter {
@@ -87,10 +65,17 @@
     }
   }
 
+  .library-menu-control-buttons {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 0.625rem;
+  }
+
   .library-menu-browse-button {
-    margin: 1rem auto;
+    flex: 1;
 
-    padding: 0.875rem 1rem;
+    height: var(--lg-button-size);
 
     display: flex;
     align-items: center;
@@ -122,30 +107,19 @@
     }
   }
 
-  .library-menu-browse-button--mobile {
-    min-height: 22px;
-    margin-left: auto;
-    a {
-      padding-right: 0;
-    }
+  &.excalidraw--mobile .library-menu-browse-button {
+    height: var(--default-button-size);
   }
 
-  .layer-ui__sidebar__header .dropdown-menu {
-    &.dropdown-menu--mobile {
-      top: 100%;
-    }
-    .dropdown-menu-container {
-      --gap: 0;
-      z-index: 1;
-      position: absolute;
-      top: 100%;
-      left: 0;
-
-      :root[dir="rtl"] & {
-        right: 0;
-        left: auto;
-      }
+  .layer-ui__library .dropdown-menu {
+    width: auto;
+    top: initial;
+    right: 0;
+    left: initial;
+    bottom: 100%;
+    margin-bottom: 0.625rem;
 
+    .dropdown-menu-container {
       width: 196px;
       box-shadow: var(--library-dropdown-shadow);
       border-radius: var(--border-radius-lg);

+ 35 - 177
src/components/LibraryMenu.tsx

@@ -1,11 +1,4 @@
-import {
-  useRef,
-  useState,
-  useEffect,
-  useCallback,
-  RefObject,
-  forwardRef,
-} from "react";
+import React, { useState, useCallback } from "react";
 import Library, {
   distributeLibraryItemsOnSquareGrid,
   libraryItemsAtom,
@@ -13,65 +6,29 @@ import Library, {
 import { t } from "../i18n";
 import { randomId } from "../random";
 import { LibraryItems, LibraryItem, AppState, ExcalidrawProps } from "../types";
-
-import "./LibraryMenu.scss";
 import LibraryMenuItems from "./LibraryMenuItems";
-import { EVENT } from "../constants";
-import { KEYS } from "../keys";
 import { trackEvent } from "../analytics";
-import { useAtom } from "jotai";
+import { atom, useAtom } from "jotai";
 import { jotaiScope } from "../jotai";
 import Spinner from "./Spinner";
 import {
-  useDevice,
+  useApp,
+  useAppProps,
   useExcalidrawElements,
   useExcalidrawSetAppState,
 } from "./App";
-import { Sidebar } from "./Sidebar/Sidebar";
 import { getSelectedElements } from "../scene";
-import { NonDeletedExcalidrawElement } from "../element/types";
-import { LibraryMenuHeader } from "./LibraryMenuHeaderContent";
-import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
+import { useUIAppState } from "../context/ui-appState";
 
-const useOnClickOutside = (
-  ref: RefObject<HTMLElement>,
-  cb: (event: MouseEvent) => void,
-) => {
-  useEffect(() => {
-    const listener = (event: MouseEvent) => {
-      if (!ref.current) {
-        return;
-      }
-
-      if (
-        event.target instanceof Element &&
-        (ref.current.contains(event.target) ||
-          !document.body.contains(event.target))
-      ) {
-        return;
-      }
+import "./LibraryMenu.scss";
+import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
 
-      cb(event);
-    };
-    document.addEventListener("pointerdown", listener, false);
+export const isLibraryMenuOpenAtom = atom(false);
 
-    return () => {
-      document.removeEventListener("pointerdown", listener);
-    };
-  }, [ref, cb]);
+const LibraryMenuWrapper = ({ children }: { children: React.ReactNode }) => {
+  return <div className="layer-ui__library">{children}</div>;
 };
 
-const LibraryMenuWrapper = forwardRef<
-  HTMLDivElement,
-  { children: React.ReactNode }
->(({ children }, ref) => {
-  return (
-    <div ref={ref} className="layer-ui__library">
-      {children}
-    </div>
-  );
-});
-
 export const LibraryMenuContent = ({
   onInsertLibraryItems,
   pendingElements,
@@ -158,81 +115,31 @@ export const LibraryMenuContent = ({
         theme={appState.theme}
       />
       {showBtn && (
-        <LibraryMenuBrowseButton
+        <LibraryMenuControlButtons
+          style={{ padding: "16px 12px 0 12px" }}
           id={id}
           libraryReturnUrl={libraryReturnUrl}
           theme={appState.theme}
+          selectedItems={selectedItems}
+          onSelectItems={onSelectItems}
         />
       )}
     </LibraryMenuWrapper>
   );
 };
 
-export const LibraryMenu: React.FC<{
-  appState: AppState;
-  onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
-  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
-  focusContainer: () => void;
-  library: Library;
-  id: string;
-}> = ({
-  appState,
-  onInsertElements,
-  libraryReturnUrl,
-  focusContainer,
-  library,
-  id,
-}) => {
+/**
+ * This component is meant to be rendered inside <Sidebar.Tab/> inside our
+ * <DefaultSidebar/> or host apps Sidebar components.
+ */
+export const LibraryMenu = () => {
+  const { library, id, onInsertElements } = useApp();
+  const appProps = useAppProps();
+  const appState = useUIAppState();
   const setAppState = useExcalidrawSetAppState();
   const elements = useExcalidrawElements();
-  const device = useDevice();
 
   const [selectedItems, setSelectedItems] = useState<LibraryItem["id"][]>([]);
-  const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
-
-  const ref = useRef<HTMLDivElement | null>(null);
-
-  const closeLibrary = useCallback(() => {
-    const isDialogOpen = !!document.querySelector(".Dialog");
-
-    // Prevent closing if any dialog is open
-    if (isDialogOpen) {
-      return;
-    }
-    setAppState({ openSidebar: null });
-  }, [setAppState]);
-
-  useOnClickOutside(
-    ref,
-    useCallback(
-      (event) => {
-        // If click on the library icon, do nothing so that LibraryButton
-        // can toggle library menu
-        if ((event.target as Element).closest(".ToolIcon__library")) {
-          return;
-        }
-        if (!appState.isSidebarDocked || !device.canDeviceFitSidebar) {
-          closeLibrary();
-        }
-      },
-      [closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar],
-    ),
-  );
-
-  useEffect(() => {
-    const handleKeyDown = (event: KeyboardEvent) => {
-      if (
-        event.key === KEYS.ESCAPE &&
-        (!appState.isSidebarDocked || !device.canDeviceFitSidebar)
-      ) {
-        closeLibrary();
-      }
-    };
-    document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
-    return () => {
-      document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
-    };
-  }, [closeLibrary, appState.isSidebarDocked, device.canDeviceFitSidebar]);
 
   const deselectItems = useCallback(() => {
     setAppState({
@@ -241,69 +148,20 @@ export const LibraryMenu: React.FC<{
     });
   }, [setAppState]);
 
-  const removeFromLibrary = useCallback(
-    async (libraryItems: LibraryItems) => {
-      const nextItems = libraryItems.filter(
-        (item) => !selectedItems.includes(item.id),
-      );
-      library.setLibrary(nextItems).catch(() => {
-        setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
-      });
-      setSelectedItems([]);
-    },
-    [library, setAppState, selectedItems, setSelectedItems],
-  );
-
-  const resetLibrary = useCallback(() => {
-    library.resetLibrary();
-    focusContainer();
-  }, [library, focusContainer]);
-
   return (
-    <Sidebar
-      __isInternal
-      // necessary to remount when switching between internal
-      // and custom (host app) sidebar, so that the `props.onClose`
-      // is colled correctly
-      key="library"
-      className="layer-ui__library-sidebar"
-      initialDockedState={appState.isSidebarDocked}
-      onDock={(docked) => {
-        trackEvent(
-          "library",
-          `toggleLibraryDock (${docked ? "dock" : "undock"})`,
-          `sidebar (${device.isMobile ? "mobile" : "desktop"})`,
-        );
+    <LibraryMenuContent
+      pendingElements={getSelectedElements(elements, appState, true)}
+      onInsertLibraryItems={(libraryItems) => {
+        onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
       }}
-      ref={ref}
-    >
-      <Sidebar.Header className="layer-ui__library-header">
-        <LibraryMenuHeader
-          appState={appState}
-          setAppState={setAppState}
-          selectedItems={selectedItems}
-          onSelectItems={setSelectedItems}
-          library={library}
-          onRemoveFromLibrary={() =>
-            removeFromLibrary(libraryItemsData.libraryItems)
-          }
-          resetLibrary={resetLibrary}
-        />
-      </Sidebar.Header>
-      <LibraryMenuContent
-        pendingElements={getSelectedElements(elements, appState, true)}
-        onInsertLibraryItems={(libraryItems) => {
-          onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
-        }}
-        onAddToLibrary={deselectItems}
-        setAppState={setAppState}
-        libraryReturnUrl={libraryReturnUrl}
-        library={library}
-        id={id}
-        appState={appState}
-        selectedItems={selectedItems}
-        onSelectItems={setSelectedItems}
-      />
-    </Sidebar>
+      onAddToLibrary={deselectItems}
+      setAppState={setAppState}
+      libraryReturnUrl={appProps.libraryReturnUrl}
+      library={library}
+      id={id}
+      appState={appState}
+      selectedItems={selectedItems}
+      onSelectItems={setSelectedItems}
+    />
   );
 };

+ 33 - 0
src/components/LibraryMenuControlButtons.tsx

@@ -0,0 +1,33 @@
+import { LibraryItem, ExcalidrawProps, AppState } from "../types";
+import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
+import { LibraryDropdownMenu } from "./LibraryMenuHeaderContent";
+
+export const LibraryMenuControlButtons = ({
+  selectedItems,
+  onSelectItems,
+  libraryReturnUrl,
+  theme,
+  id,
+  style,
+}: {
+  selectedItems: LibraryItem["id"][];
+  onSelectItems: (id: LibraryItem["id"][]) => void;
+  libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
+  theme: AppState["theme"];
+  id: string;
+  style: React.CSSProperties;
+}) => {
+  return (
+    <div className="library-menu-control-buttons" style={style}>
+      <LibraryMenuBrowseButton
+        id={id}
+        libraryReturnUrl={libraryReturnUrl}
+        theme={theme}
+      />
+      <LibraryDropdownMenu
+        selectedItems={selectedItems}
+        onSelectItems={onSelectItems}
+      />
+    </div>
+  );
+};

+ 55 - 10
src/components/LibraryMenuHeaderContent.tsx

@@ -1,8 +1,10 @@
-import React, { useCallback, useState } from "react";
-import { saveLibraryAsJSON } from "../data/json";
-import Library, { libraryItemsAtom } from "../data/library";
+import { useCallback, useState } from "react";
 import { t } from "../i18n";
+import { jotaiScope } from "../jotai";
 import { AppState, LibraryItem, LibraryItems } from "../types";
+import { useApp, useExcalidrawAppState, useExcalidrawSetAppState } from "./App";
+import { saveLibraryAsJSON } from "../data/json";
+import Library, { libraryItemsAtom } from "../data/library";
 import {
   DotsIcon,
   ExportIcon,
@@ -13,22 +15,19 @@ import {
 import { ToolButton } from "./ToolButton";
 import { fileOpen } from "../data/filesystem";
 import { muteFSAbortError } from "../utils";
-import { atom, useAtom } from "jotai";
-import { jotaiScope } from "../jotai";
+import { useAtom } from "jotai";
 import ConfirmDialog from "./ConfirmDialog";
 import PublishLibrary from "./PublishLibrary";
 import { Dialog } from "./Dialog";
-
 import DropdownMenu from "./dropdownMenu/DropdownMenu";
-
-export const isLibraryMenuOpenAtom = atom(false);
+import { isLibraryMenuOpenAtom } from "./LibraryMenu";
 
 const getSelectedItems = (
   libraryItems: LibraryItems,
   selectedItems: LibraryItem["id"][],
 ) => libraryItems.filter((item) => selectedItems.includes(item.id));
 
-export const LibraryMenuHeader: React.FC<{
+export const LibraryDropdownMenuButton: React.FC<{
   setAppState: React.Component<any, AppState>["setState"];
   selectedItems: LibraryItem["id"][];
   library: Library;
@@ -50,6 +49,7 @@ export const LibraryMenuHeader: React.FC<{
     isLibraryMenuOpenAtom,
     jotaiScope,
   );
+
   const renderRemoveLibAlert = useCallback(() => {
     const content = selectedItems.length
       ? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
@@ -181,7 +181,6 @@ export const LibraryMenuHeader: React.FC<{
     return (
       <DropdownMenu open={isLibraryMenuOpen}>
         <DropdownMenu.Trigger
-          className="Sidebar__dropdown-btn"
           onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
         >
           {DotsIcon}
@@ -230,6 +229,7 @@ export const LibraryMenuHeader: React.FC<{
       </DropdownMenu>
     );
   };
+
   return (
     <div style={{ position: "relative" }}>
       {renderLibraryMenu()}
@@ -261,3 +261,48 @@ export const LibraryMenuHeader: React.FC<{
     </div>
   );
 };
+
+export const LibraryDropdownMenu = ({
+  selectedItems,
+  onSelectItems,
+}: {
+  selectedItems: LibraryItem["id"][];
+  onSelectItems: (id: LibraryItem["id"][]) => void;
+}) => {
+  const { library } = useApp();
+  const appState = useExcalidrawAppState();
+  const setAppState = useExcalidrawSetAppState();
+
+  const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
+
+  const removeFromLibrary = useCallback(
+    async (libraryItems: LibraryItems) => {
+      const nextItems = libraryItems.filter(
+        (item) => !selectedItems.includes(item.id),
+      );
+      library.setLibrary(nextItems).catch(() => {
+        setAppState({ errorMessage: t("alerts.errorRemovingFromLibrary") });
+      });
+      onSelectItems([]);
+    },
+    [library, setAppState, selectedItems, onSelectItems],
+  );
+
+  const resetLibrary = useCallback(() => {
+    library.resetLibrary();
+  }, [library]);
+
+  return (
+    <LibraryDropdownMenuButton
+      appState={appState}
+      setAppState={setAppState}
+      selectedItems={selectedItems}
+      onSelectItems={onSelectItems}
+      library={library}
+      onRemoveFromLibrary={() =>
+        removeFromLibrary(libraryItemsData.libraryItems)
+      }
+      resetLibrary={resetLibrary}
+    />
+  );
+};

+ 2 - 2
src/components/LibraryMenuItems.scss

@@ -47,7 +47,7 @@
 
     &__items {
       row-gap: 0.5rem;
-      padding: var(--container-padding-y) var(--container-padding-x);
+      padding: var(--container-padding-y) 0;
       flex: 1;
       overflow-y: auto;
       overflow-x: hidden;
@@ -61,7 +61,7 @@
       margin-bottom: 0.75rem;
 
       &--excal {
-        margin-top: 2.5rem;
+        margin-top: 2rem;
       }
     }
 

+ 8 - 14
src/components/LibraryMenuItems.tsx

@@ -10,9 +10,8 @@ import Stack from "./Stack";
 import "./LibraryMenuItems.scss";
 import { MIME_TYPES } from "../constants";
 import Spinner from "./Spinner";
-import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton";
-import clsx from "clsx";
 import { duplicateElements } from "../element/newElement";
+import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
 
 const CELLS_PER_ROW = 4;
 
@@ -201,11 +200,7 @@ const LibraryMenuItems = ({
     (item) => item.status === "published",
   );
 
-  const showBtn =
-    !libraryItems.length &&
-    !unpublishedItems.length &&
-    !publishedItems.length &&
-    !pendingElements.length;
+  const showBtn = !libraryItems.length && !pendingElements.length;
 
   return (
     <div
@@ -215,7 +210,7 @@ const LibraryMenuItems = ({
         unpublishedItems.length ||
         publishedItems.length
           ? { justifyContent: "flex-start" }
-          : {}
+          : { borderBottom: 0 }
       }
     >
       <Stack.Col
@@ -251,11 +246,7 @@ const LibraryMenuItems = ({
           </div>
           {!pendingElements.length && !unpublishedItems.length ? (
             <div className="library-menu-items__no-items">
-              <div
-                className={clsx({
-                  "library-menu-items__no-items__label": showBtn,
-                })}
-              >
+              <div className="library-menu-items__no-items__label">
                 {t("library.noItems")}
               </div>
               <div className="library-menu-items__no-items__hint">
@@ -303,10 +294,13 @@ const LibraryMenuItems = ({
         </>
 
         {showBtn && (
-          <LibraryMenuBrowseButton
+          <LibraryMenuControlButtons
+            style={{ padding: "16px 0", width: "100%" }}
             id={id}
             libraryReturnUrl={libraryReturnUrl}
             theme={theme}
+            selectedItems={selectedItems}
+            onSelectItems={onSelectItems}
           />
         )}
       </Stack.Col>

+ 11 - 12
src/components/MobileMenu.tsx

@@ -13,13 +13,12 @@ import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
 import { Section } from "./Section";
 import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
 import { LockButton } from "./LockButton";
-import { LibraryButton } from "./LibraryButton";
 import { PenModeButton } from "./PenModeButton";
 import { Stats } from "./Stats";
 import { actionToggleStats } from "../actions";
 import { HandButton } from "./HandButton";
 import { isHandToolActive } from "../appState";
-import { useTunnels } from "./context/tunnels";
+import { useTunnels } from "../context/tunnels";
 
 type MobileMenuProps = {
   appState: AppState;
@@ -60,11 +59,15 @@ export const MobileMenu = ({
   device,
   renderWelcomeScreen,
 }: MobileMenuProps) => {
-  const { welcomeScreenCenterTunnel, mainMenuTunnel } = useTunnels();
+  const {
+    WelcomeScreenCenterTunnel,
+    MainMenuTunnel,
+    DefaultSidebarTriggerTunnel,
+  } = useTunnels();
   const renderToolbar = () => {
     return (
       <FixedSideContainer side="top" className="App-top-bar">
-        {renderWelcomeScreen && <welcomeScreenCenterTunnel.Out />}
+        {renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />}
         <Section heading="shapes">
           {(heading: React.ReactNode) => (
             <Stack.Col gap={4} align="center">
@@ -88,11 +91,7 @@ export const MobileMenu = ({
                 {renderTopRightUI && renderTopRightUI(true, appState)}
                 <div className="mobile-misc-tools-container">
                   {!appState.viewModeEnabled && (
-                    <LibraryButton
-                      appState={appState}
-                      setAppState={setAppState}
-                      isMobile
-                    />
+                    <DefaultSidebarTriggerTunnel.Out />
                   )}
                   <PenModeButton
                     checked={appState.penMode}
@@ -132,14 +131,14 @@ export const MobileMenu = ({
     if (appState.viewModeEnabled) {
       return (
         <div className="App-toolbar-content">
-          <mainMenuTunnel.Out />
+          <MainMenuTunnel.Out />
         </div>
       );
     }
 
     return (
       <div className="App-toolbar-content">
-        <mainMenuTunnel.Out />
+        <MainMenuTunnel.Out />
         {actionManager.renderAction("toggleEditMenu")}
         {actionManager.renderAction("undo")}
         {actionManager.renderAction("redo")}
@@ -190,7 +189,7 @@ export const MobileMenu = ({
             {renderAppToolbar()}
             {appState.scrolledOutside &&
               !appState.openMenu &&
-              appState.openSidebar !== "library" && (
+              !appState.openSidebar && (
                 <button
                   className="scroll-back-to-content"
                   onClick={() => {

+ 4 - 4
src/components/PasteChartDialog.tsx

@@ -5,7 +5,8 @@ import { ChartElements, renderSpreadsheet, Spreadsheet } from "../charts";
 import { ChartType } from "../element/types";
 import { t } from "../i18n";
 import { exportToSvg } from "../scene/export";
-import { AppState, LibraryItem } from "../types";
+import { AppState } from "../types";
+import { useApp } from "./App";
 import { Dialog } from "./Dialog";
 import "./PasteChartDialog.scss";
 
@@ -78,13 +79,12 @@ export const PasteChartDialog = ({
   setAppState,
   appState,
   onClose,
-  onInsertChart,
 }: {
   appState: AppState;
   onClose: () => void;
   setAppState: React.Component<any, AppState>["setState"];
-  onInsertChart: (elements: LibraryItem["elements"]) => void;
 }) => {
+  const { onInsertElements } = useApp();
   const handleClose = React.useCallback(() => {
     if (onClose) {
       onClose();
@@ -92,7 +92,7 @@ export const PasteChartDialog = ({
   }, [onClose]);
 
   const handleChartClick = (chartType: ChartType, elements: ChartElements) => {
-    onInsertChart(elements);
+    onInsertElements(elements);
     trackEvent("magic", "chart", chartType);
     setAppState({
       currentChartType: chartType,

+ 122 - 81
src/components/Sidebar/Sidebar.scss

@@ -2,67 +2,26 @@
 @import "../../css/variables.module";
 
 .excalidraw {
-  .Sidebar {
-    &__close-btn,
-    &__pin-btn,
-    &__dropdown-btn {
-      @include outlineButtonStyles;
-      width: var(--lg-button-size);
-      height: var(--lg-button-size);
-      padding: 0;
-
-      svg {
-        width: var(--lg-icon-size);
-        height: var(--lg-icon-size);
-      }
-    }
-
-    &__pin-btn {
-      &--pinned {
-        background-color: var(--color-primary);
-        border-color: var(--color-primary);
-
-        svg {
-          color: #fff;
-        }
-
-        &:hover,
-        &:active {
-          background-color: var(--color-primary-darker);
-        }
-      }
-    }
-  }
-
-  &.theme--dark {
-    .Sidebar {
-      &__pin-btn {
-        &--pinned {
-          svg {
-            color: var(--color-gray-90);
-          }
-        }
-      }
-    }
-  }
-
-  .layer-ui__sidebar {
+  .sidebar {
+    display: flex;
+    flex-direction: column;
     position: absolute;
     top: 0;
     bottom: 0;
     right: 0;
     z-index: 5;
     margin: 0;
+    padding: 0;
+    box-sizing: border-box;
+
+    background-color: var(--sidebar-bg-color);
+    box-shadow: var(--sidebar-shadow);
 
     :root[dir="rtl"] & {
       left: 0;
       right: auto;
     }
 
-    background-color: var(--sidebar-bg-color);
-
-    box-shadow: var(--sidebar-shadow);
-
     &--docked {
       box-shadow: none;
     }
@@ -77,52 +36,134 @@
       border-right: 1px solid var(--sidebar-border-color);
       border-left: 0;
     }
+  }
 
-    padding: 0;
+  // ---------------------------- sidebar header ------------------------------
+
+  .sidebar__header {
     box-sizing: border-box;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    width: 100%;
+    padding-top: 1rem;
+    padding-bottom: 1rem;
+  }
 
-    .Island {
-      box-shadow: none;
-    }
+  .sidebar__header__buttons {
+    gap: 0;
+    display: flex;
+    align-items: center;
+    margin-left: auto;
 
-    .ToolIcon__icon {
-      border-radius: var(--border-radius-md);
-    }
+    button {
+      @include outlineButtonStyles;
+      --button-bg: transparent;
+      border: 0 !important;
+
+      width: var(--lg-button-size);
+      height: var(--lg-button-size);
+      padding: 0;
 
-    .ToolIcon__icon__close {
-      .Modal__close {
-        width: calc(var(--space-factor) * 7);
-        height: calc(var(--space-factor) * 7);
-        display: flex;
-        justify-content: center;
-        align-items: center;
-        color: var(--color-text);
+      svg {
+        width: var(--lg-icon-size);
+        height: var(--lg-icon-size);
+      }
+
+      &:hover {
+        background: var(--button-hover-bg, var(--island-bg-color));
       }
     }
 
-    .Island {
-      --padding: 0;
-      background-color: var(--island-bg-color);
-      border-radius: var(--border-radius-lg);
-      padding: calc(var(--padding) * var(--space-factor));
-      position: relative;
-      transition: box-shadow 0.5s ease-in-out;
+    .sidebar__dock.selected {
+      svg {
+        stroke: var(--color-primary);
+        fill: var(--color-primary);
+      }
     }
   }
 
-  .layer-ui__sidebar__header {
-    box-sizing: border-box;
+  // ---------------------------- sidebar tabs ------------------------------
+
+  .sidebar-tabs-root {
     display: flex;
-    justify-content: space-between;
-    align-items: center;
-    width: 100%;
-    padding: 1rem;
-    border-bottom: 1px solid var(--sidebar-border-color);
+    flex-direction: column;
+    flex: 1 1 auto;
+    padding: 1rem 0.75rem;
+
+    [role="tabpanel"] {
+      flex: 1;
+      outline: none;
+
+      flex: 1 1 auto;
+      display: flex;
+      flex-direction: column;
+      outline: none;
+    }
+
+    [role="tabpanel"][data-state="inactive"] {
+      display: none !important;
+    }
+
+    [role="tablist"] {
+      display: grid;
+      gap: 1rem;
+      grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
+    }
+  }
+
+  .sidebar-tabs-root > .sidebar__header {
+    padding-top: 0;
+    padding-bottom: 1rem;
   }
 
-  .layer-ui__sidebar__header__buttons {
+  .sidebar-tab-trigger {
+    --button-width: auto;
+    --button-bg: transparent;
+    --button-hover-bg: transparent;
+    --button-active-bg: var(--color-primary);
+    --button-hover-color: var(--color-primary);
+    --button-hover-border: var(--color-primary);
+
+    &[data-state="active"] {
+      --button-bg: var(--color-primary);
+      --button-hover-bg: var(--color-primary-darker);
+      --button-hover-color: var(--color-icon-white);
+      --button-border: var(--color-primary);
+      color: var(--color-icon-white);
+    }
+  }
+
+  // ---------------------------- default sidebar ------------------------------
+
+  .default-sidebar {
     display: flex;
-    align-items: center;
-    gap: 0.625rem;
+    flex-direction: column;
+
+    .sidebar-triggers {
+      $padding: 2px;
+      $border: 1px;
+      display: flex;
+      gap: 0;
+      padding: $padding;
+      // offset by padding + border to vertically center the list with sibling
+      // buttons (both from top and bototm, due to flex layout)
+      margin-top: -#{$padding + $border};
+      margin-bottom: -#{$padding + $border};
+      border: $border solid var(--sidebar-border-color);
+      background: var(--default-bg-color);
+      border-radius: 0.625rem;
+
+      .sidebar-tab-trigger {
+        height: var(--lg-button-size);
+        width: var(--lg-button-size);
+
+        border: none;
+      }
+    }
+
+    .sidebar__header {
+      border-bottom: 1px solid var(--sidebar-border-color);
+    }
   }
 }

+ 253 - 277
src/components/Sidebar/Sidebar.test.tsx

@@ -1,8 +1,9 @@
 import React from "react";
+import { DEFAULT_SIDEBAR } from "../../constants";
 import { Excalidraw, Sidebar } from "../../packages/excalidraw/index";
 import {
-  act,
   fireEvent,
+  GlobalTestState,
   queryAllByTestId,
   queryByTestId,
   render,
@@ -10,346 +11,321 @@ import {
   withExcalidrawDimensions,
 } from "../../tests/test-utils";
 
-describe("Sidebar", () => {
-  it("should render custom sidebar", async () => {
-    const { container } = await render(
-      <Excalidraw
-        initialData={{ appState: { openSidebar: "customSidebar" } }}
-        renderSidebar={() => (
-          <Sidebar>
-            <div id="test-sidebar-content">42</div>
-          </Sidebar>
-        )}
-      />,
+export const assertSidebarDockButton = async <T extends boolean>(
+  hasDockButton: T,
+): Promise<
+  T extends false
+    ? { dockButton: null; sidebar: HTMLElement }
+    : { dockButton: HTMLElement; sidebar: HTMLElement }
+> => {
+  const sidebar =
+    GlobalTestState.renderResult.container.querySelector<HTMLElement>(
+      ".sidebar",
     );
+  expect(sidebar).not.toBe(null);
+  const dockButton = queryByTestId(sidebar!, "sidebar-dock");
+  if (hasDockButton) {
+    expect(dockButton).not.toBe(null);
+    return { dockButton: dockButton!, sidebar: sidebar! } as any;
+  }
+  expect(dockButton).toBe(null);
+  return { dockButton: null, sidebar: sidebar! } as any;
+};
+
+export const assertExcalidrawWithSidebar = async (
+  sidebar: React.ReactNode,
+  name: string,
+  test: () => void,
+) => {
+  await render(
+    <Excalidraw initialData={{ appState: { openSidebar: { name } } }}>
+      {sidebar}
+    </Excalidraw>,
+  );
+  await withExcalidrawDimensions({ width: 1920, height: 1080 }, test);
+};
 
-    const node = container.querySelector("#test-sidebar-content");
-    expect(node).not.toBe(null);
-  });
-
-  it("should render custom sidebar header", async () => {
-    const { container } = await render(
-      <Excalidraw
-        initialData={{ appState: { openSidebar: "customSidebar" } }}
-        renderSidebar={() => (
-          <Sidebar>
-            <Sidebar.Header>
-              <div id="test-sidebar-header-content">42</div>
-            </Sidebar.Header>
-          </Sidebar>
-        )}
-      />,
-    );
-
-    const node = container.querySelector("#test-sidebar-header-content");
-    expect(node).not.toBe(null);
-    // make sure we don't render the default fallback header,
-    // just the custom one
-    expect(queryAllByTestId(container, "sidebar-header").length).toBe(1);
-  });
-
-  it("should render only one sidebar and prefer the custom one", async () => {
-    const { container } = await render(
-      <Excalidraw
-        initialData={{ appState: { openSidebar: "customSidebar" } }}
-        renderSidebar={() => (
-          <Sidebar>
+describe("Sidebar", () => {
+  describe("General behavior", () => {
+    it("should render custom sidebar", async () => {
+      const { container } = await render(
+        <Excalidraw
+          initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
+        >
+          <Sidebar name="customSidebar">
             <div id="test-sidebar-content">42</div>
           </Sidebar>
-        )}
-      />,
-    );
+        </Excalidraw>,
+      );
 
-    await waitFor(() => {
-      // make sure the custom sidebar is rendered
       const node = container.querySelector("#test-sidebar-content");
       expect(node).not.toBe(null);
-
-      // make sure only one sidebar is rendered
-      const sidebars = container.querySelectorAll(".layer-ui__sidebar");
-      expect(sidebars.length).toBe(1);
     });
-  });
 
-  it("should always render custom sidebar with close button & close on click", async () => {
-    const onClose = jest.fn();
-    const CustomExcalidraw = () => {
-      return (
+    it("should render only one sidebar and prefer the custom one", async () => {
+      const { container } = await render(
         <Excalidraw
-          initialData={{ appState: { openSidebar: "customSidebar" } }}
-          renderSidebar={() => (
-            <Sidebar className="test-sidebar" onClose={onClose}>
-              hello
-            </Sidebar>
-          )}
-        />
-      );
-    };
-
-    const { container } = await render(<CustomExcalidraw />);
-
-    const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
-    expect(sidebar).not.toBe(null);
-    const closeButton = queryByTestId(sidebar!, "sidebar-close")!;
-    expect(closeButton).not.toBe(null);
-
-    fireEvent.click(closeButton);
-    await waitFor(() => {
-      expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(null);
-      expect(onClose).toHaveBeenCalled();
-    });
-  });
-
-  it("should render custom sidebar with dock (irrespective of onDock prop)", async () => {
-    const CustomExcalidraw = () => {
-      return (
-        <Excalidraw
-          initialData={{ appState: { openSidebar: "customSidebar" } }}
-          renderSidebar={() => (
-            <Sidebar className="test-sidebar">hello</Sidebar>
-          )}
-        />
+          initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
+        >
+          <Sidebar name="customSidebar">
+            <div id="test-sidebar-content">42</div>
+          </Sidebar>
+        </Excalidraw>,
       );
-    };
-
-    const { container } = await render(<CustomExcalidraw />);
-
-    // should show dock button when the sidebar fits to be docked
-    // -------------------------------------------------------------------------
-
-    await withExcalidrawDimensions({ width: 1920, height: 1080 }, () => {
-      const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
-      expect(sidebar).not.toBe(null);
-      const closeButton = queryByTestId(sidebar!, "sidebar-dock");
-      expect(closeButton).not.toBe(null);
-    });
 
-    // should not show dock button when the sidebar does not fit to be docked
-    // -------------------------------------------------------------------------
+      await waitFor(() => {
+        // make sure the custom sidebar is rendered
+        const node = container.querySelector("#test-sidebar-content");
+        expect(node).not.toBe(null);
 
-    await withExcalidrawDimensions({ width: 400, height: 1080 }, () => {
-      const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
-      expect(sidebar).not.toBe(null);
-      const closeButton = queryByTestId(sidebar!, "sidebar-dock");
-      expect(closeButton).toBe(null);
+        // make sure only one sidebar is rendered
+        const sidebars = container.querySelectorAll(".sidebar");
+        expect(sidebars.length).toBe(1);
+      });
     });
-  });
-
-  it("should support controlled docking", async () => {
-    let _setDockable: (dockable: boolean) => void = null!;
 
-    const CustomExcalidraw = () => {
-      const [dockable, setDockable] = React.useState(false);
-      _setDockable = setDockable;
-      return (
-        <Excalidraw
-          initialData={{ appState: { openSidebar: "customSidebar" } }}
-          renderSidebar={() => (
-            <Sidebar
-              className="test-sidebar"
-              docked={false}
-              dockable={dockable}
-            >
-              hello
-            </Sidebar>
-          )}
-        />
+    it("should toggle sidebar using props.toggleMenu()", async () => {
+      const { container } = await render(
+        <Excalidraw>
+          <Sidebar name="customSidebar">
+            <div id="test-sidebar-content">42</div>
+          </Sidebar>
+        </Excalidraw>,
       );
-    };
-
-    const { container } = await render(<CustomExcalidraw />);
 
-    await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
-      // should not show dock button when `dockable` is `false`
+      // sidebar isn't rendered initially
       // -------------------------------------------------------------------------
-
-      act(() => {
-        _setDockable(false);
-      });
-
       await waitFor(() => {
-        const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
-        expect(sidebar).not.toBe(null);
-        const closeButton = queryByTestId(sidebar!, "sidebar-dock");
-        expect(closeButton).toBe(null);
+        const node = container.querySelector("#test-sidebar-content");
+        expect(node).toBe(null);
       });
 
-      // should show dock button when `dockable` is `true`, even if `docked`
-      // prop is set
+      // toggle sidebar on
       // -------------------------------------------------------------------------
-
-      act(() => {
-        _setDockable(true);
-      });
+      expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true);
 
       await waitFor(() => {
-        const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
-        expect(sidebar).not.toBe(null);
-        const closeButton = queryByTestId(sidebar!, "sidebar-dock");
-        expect(closeButton).not.toBe(null);
+        const node = container.querySelector("#test-sidebar-content");
+        expect(node).not.toBe(null);
       });
-    });
-  });
-
-  it("should support controlled docking", async () => {
-    let _setDocked: (docked?: boolean) => void = null!;
-
-    const CustomExcalidraw = () => {
-      const [docked, setDocked] = React.useState<boolean | undefined>();
-      _setDocked = setDocked;
-      return (
-        <Excalidraw
-          initialData={{ appState: { openSidebar: "customSidebar" } }}
-          renderSidebar={() => (
-            <Sidebar className="test-sidebar" docked={docked}>
-              hello
-            </Sidebar>
-          )}
-        />
-      );
-    };
-
-    const { container } = await render(<CustomExcalidraw />);
 
-    const { h } = window;
-
-    await withExcalidrawDimensions({ width: 1920, height: 1080 }, async () => {
-      const dockButton = await waitFor(() => {
-        const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
-        expect(sidebar).not.toBe(null);
-        const dockBotton = queryByTestId(sidebar!, "sidebar-dock");
-        expect(dockBotton).not.toBe(null);
-        return dockBotton!;
-      });
-
-      const dockButtonInput = dockButton.querySelector("input")!;
-
-      // should not show dock button when `dockable` is `false`
+      // toggle sidebar off
       // -------------------------------------------------------------------------
+      expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(false);
 
-      expect(h.state.isSidebarDocked).toBe(false);
-
-      fireEvent.click(dockButtonInput);
       await waitFor(() => {
-        expect(h.state.isSidebarDocked).toBe(true);
-        expect(dockButtonInput).toBeChecked();
+        const node = container.querySelector("#test-sidebar-content");
+        expect(node).toBe(null);
       });
 
-      fireEvent.click(dockButtonInput);
-      await waitFor(() => {
-        expect(h.state.isSidebarDocked).toBe(false);
-        expect(dockButtonInput).not.toBeChecked();
-      });
-
-      // shouldn't update `appState.isSidebarDocked` when the sidebar
-      // is controlled (`docked` prop is set), as host apps should handle
-      // the state themselves
+      // force-toggle sidebar off (=> still hidden)
       // -------------------------------------------------------------------------
-
-      act(() => {
-        _setDocked(true);
-      });
+      expect(
+        window.h.app.toggleSidebar({ name: "customSidebar", force: false }),
+      ).toBe(false);
 
       await waitFor(() => {
-        expect(dockButtonInput).toBeChecked();
-        expect(h.state.isSidebarDocked).toBe(false);
-        expect(dockButtonInput).toBeChecked();
+        const node = container.querySelector("#test-sidebar-content");
+        expect(node).toBe(null);
       });
 
-      fireEvent.click(dockButtonInput);
+      // force-toggle sidebar on
+      // -------------------------------------------------------------------------
+      expect(
+        window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
+      ).toBe(true);
+      expect(
+        window.h.app.toggleSidebar({ name: "customSidebar", force: true }),
+      ).toBe(true);
+
       await waitFor(() => {
-        expect(h.state.isSidebarDocked).toBe(false);
-        expect(dockButtonInput).toBeChecked();
+        const node = container.querySelector("#test-sidebar-content");
+        expect(node).not.toBe(null);
       });
 
-      // the `appState.isSidebarDocked` should remain untouched when
-      // `props.docked` is set to `false`, and user toggles
+      // toggle library (= hide custom sidebar)
       // -------------------------------------------------------------------------
-
-      act(() => {
-        _setDocked(false);
-        h.setState({ isSidebarDocked: true });
-      });
+      expect(window.h.app.toggleSidebar({ name: DEFAULT_SIDEBAR.name })).toBe(
+        true,
+      );
 
       await waitFor(() => {
-        expect(h.state.isSidebarDocked).toBe(true);
-        expect(dockButtonInput).not.toBeChecked();
-      });
+        const node = container.querySelector("#test-sidebar-content");
+        expect(node).toBe(null);
 
-      fireEvent.click(dockButtonInput);
-      await waitFor(() => {
-        expect(dockButtonInput).not.toBeChecked();
-        expect(h.state.isSidebarDocked).toBe(true);
+        // make sure only one sidebar is rendered
+        const sidebars = container.querySelectorAll(".sidebar");
+        expect(sidebars.length).toBe(1);
       });
     });
   });
 
-  it("should toggle sidebar using props.toggleMenu()", async () => {
-    const { container } = await render(
-      <Excalidraw
-        renderSidebar={() => (
-          <Sidebar>
-            <div id="test-sidebar-content">42</div>
+  describe("<Sidebar.Header/>", () => {
+    it("should render custom sidebar header", async () => {
+      const { container } = await render(
+        <Excalidraw
+          initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
+        >
+          <Sidebar name="customSidebar">
+            <Sidebar.Header>
+              <div id="test-sidebar-header-content">42</div>
+            </Sidebar.Header>
           </Sidebar>
-        )}
-      />,
-    );
+        </Excalidraw>,
+      );
 
-    // sidebar isn't rendered initially
-    // -------------------------------------------------------------------------
-    await waitFor(() => {
-      const node = container.querySelector("#test-sidebar-content");
-      expect(node).toBe(null);
+      const node = container.querySelector("#test-sidebar-header-content");
+      expect(node).not.toBe(null);
+      // make sure we don't render the default fallback header,
+      // just the custom one
+      expect(queryAllByTestId(container, "sidebar-header").length).toBe(1);
     });
 
-    // toggle sidebar on
-    // -------------------------------------------------------------------------
-    expect(window.h.app.toggleMenu("customSidebar")).toBe(true);
+    it("should not render <Sidebar.Header> for custom sidebars by default", async () => {
+      const CustomExcalidraw = () => {
+        return (
+          <Excalidraw
+            initialData={{
+              appState: { openSidebar: { name: "customSidebar" } },
+            }}
+          >
+            <Sidebar name="customSidebar" className="test-sidebar">
+              hello
+            </Sidebar>
+          </Excalidraw>
+        );
+      };
 
-    await waitFor(() => {
-      const node = container.querySelector("#test-sidebar-content");
-      expect(node).not.toBe(null);
+      const { container } = await render(<CustomExcalidraw />);
+
+      const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
+      expect(sidebar).not.toBe(null);
+      const closeButton = queryByTestId(sidebar!, "sidebar-close");
+      expect(closeButton).toBe(null);
     });
 
-    // toggle sidebar off
-    // -------------------------------------------------------------------------
-    expect(window.h.app.toggleMenu("customSidebar")).toBe(false);
+    it("<Sidebar.Header> should render close button", async () => {
+      const onStateChange = jest.fn();
+      const CustomExcalidraw = () => {
+        return (
+          <Excalidraw
+            initialData={{
+              appState: { openSidebar: { name: "customSidebar" } },
+            }}
+          >
+            <Sidebar
+              name="customSidebar"
+              className="test-sidebar"
+              onStateChange={onStateChange}
+            >
+              <Sidebar.Header />
+            </Sidebar>
+          </Excalidraw>
+        );
+      };
 
-    await waitFor(() => {
-      const node = container.querySelector("#test-sidebar-content");
-      expect(node).toBe(null);
-    });
+      const { container } = await render(<CustomExcalidraw />);
 
-    // force-toggle sidebar off (=> still hidden)
-    // -------------------------------------------------------------------------
-    expect(window.h.app.toggleMenu("customSidebar", false)).toBe(false);
+      // initial open
+      expect(onStateChange).toHaveBeenCalledWith({ name: "customSidebar" });
 
-    await waitFor(() => {
-      const node = container.querySelector("#test-sidebar-content");
-      expect(node).toBe(null);
+      const sidebar = container.querySelector<HTMLElement>(".test-sidebar");
+      expect(sidebar).not.toBe(null);
+      const closeButton = queryByTestId(sidebar!, "sidebar-close")!;
+      expect(closeButton).not.toBe(null);
+
+      fireEvent.click(closeButton);
+      await waitFor(() => {
+        expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(
+          null,
+        );
+        expect(onStateChange).toHaveBeenCalledWith(null);
+      });
     });
+  });
 
-    // force-toggle sidebar on
-    // -------------------------------------------------------------------------
-    expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
-    expect(window.h.app.toggleMenu("customSidebar", true)).toBe(true);
+  describe("Docking behavior", () => {
+    it("shouldn't be user-dockable if `onDock` not supplied", async () => {
+      await assertExcalidrawWithSidebar(
+        <Sidebar name="customSidebar">
+          <Sidebar.Header />
+        </Sidebar>,
+        "customSidebar",
+        async () => {
+          await assertSidebarDockButton(false);
+        },
+      );
+    });
 
-    await waitFor(() => {
-      const node = container.querySelector("#test-sidebar-content");
-      expect(node).not.toBe(null);
+    it("shouldn't be user-dockable if `onDock` not supplied & `docked={true}`", async () => {
+      await assertExcalidrawWithSidebar(
+        <Sidebar name="customSidebar" docked={true}>
+          <Sidebar.Header />
+        </Sidebar>,
+        "customSidebar",
+        async () => {
+          await assertSidebarDockButton(false);
+        },
+      );
     });
 
-    // toggle library (= hide custom sidebar)
-    // -------------------------------------------------------------------------
-    expect(window.h.app.toggleMenu("library")).toBe(true);
+    it("shouldn't be user-dockable if `onDock` not supplied & docked={false}`", async () => {
+      await assertExcalidrawWithSidebar(
+        <Sidebar name="customSidebar" docked={false}>
+          <Sidebar.Header />
+        </Sidebar>,
+        "customSidebar",
+        async () => {
+          await assertSidebarDockButton(false);
+        },
+      );
+    });
 
-    await waitFor(() => {
-      const node = container.querySelector("#test-sidebar-content");
-      expect(node).toBe(null);
+    it("should be user-dockable when both `onDock` and `docked` supplied", async () => {
+      await render(
+        <Excalidraw
+          initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
+        >
+          <Sidebar
+            name="customSidebar"
+            className="test-sidebar"
+            onDock={() => {}}
+            docked
+          >
+            <Sidebar.Header />
+          </Sidebar>
+        </Excalidraw>,
+      );
 
-      // make sure only one sidebar is rendered
-      const sidebars = container.querySelectorAll(".layer-ui__sidebar");
-      expect(sidebars.length).toBe(1);
+      await withExcalidrawDimensions(
+        { width: 1920, height: 1080 },
+        async () => {
+          await assertSidebarDockButton(true);
+        },
+      );
+    });
+
+    it("shouldn't be user-dockable when only `onDock` supplied w/o `docked`", async () => {
+      await render(
+        <Excalidraw
+          initialData={{ appState: { openSidebar: { name: "customSidebar" } } }}
+        >
+          <Sidebar
+            name="customSidebar"
+            className="test-sidebar"
+            onDock={() => {}}
+          >
+            <Sidebar.Header />
+          </Sidebar>
+        </Excalidraw>,
+      );
+
+      await withExcalidrawDimensions(
+        { width: 1920, height: 1080 },
+        async () => {
+          await assertSidebarDockButton(false);
+        },
+      );
     });
   });
 });

+ 218 - 120
src/components/Sidebar/Sidebar.tsx

@@ -1,151 +1,249 @@
-import {
+import React, {
   useEffect,
   useLayoutEffect,
   useRef,
   useState,
   forwardRef,
+  useImperativeHandle,
+  useCallback,
+  RefObject,
 } from "react";
 import { Island } from ".././Island";
-import { atom, useAtom } from "jotai";
+import { atom, useSetAtom } from "jotai";
 import { jotaiScope } from "../../jotai";
 import {
   SidebarPropsContext,
   SidebarProps,
   SidebarPropsContextValue,
 } from "./common";
-
-import { SidebarHeaderComponents } from "./SidebarHeader";
-
-import "./Sidebar.scss";
+import { SidebarHeader } from "./SidebarHeader";
 import clsx from "clsx";
-import { useExcalidrawSetAppState } from "../App";
+import {
+  useDevice,
+  useExcalidrawAppState,
+  useExcalidrawSetAppState,
+} from "../App";
 import { updateObject } from "../../utils";
+import { KEYS } from "../../keys";
+import { EVENT } from "../../constants";
+import { SidebarTrigger } from "./SidebarTrigger";
+import { SidebarTabTriggers } from "./SidebarTabTriggers";
+import { SidebarTabTrigger } from "./SidebarTabTrigger";
+import { SidebarTabs } from "./SidebarTabs";
+import { SidebarTab } from "./SidebarTab";
 
-/** using a counter instead of boolean to handle race conditions where
- * the host app may render (mount/unmount) multiple different sidebar */
-export const hostSidebarCountersAtom = atom({ rendered: 0, docked: 0 });
+import "./Sidebar.scss";
 
-export const Sidebar = Object.assign(
-  forwardRef(
-    (
-      {
-        children,
-        onClose,
-        onDock,
-        docked,
-        /** Undocumented, may be removed later. Generally should either be
-         * `props.docked` or `appState.isSidebarDocked`. Currently serves to
-         *  prevent unwanted animation of the shadow if initially docked. */
-        //
-        // NOTE we'll want to remove this after we sort out how to subscribe to
-        // individual appState properties
-        initialDockedState = docked,
-        dockable = true,
-        className,
-        __isInternal,
-      }: SidebarProps<{
-        // NOTE sidebars we use internally inside the editor must have this flag set.
-        // It indicates that this sidebar should have lower precedence over host
-        // sidebars, if both are open.
-        /** @private internal */
-        __isInternal?: boolean;
-      }>,
-      ref: React.ForwardedRef<HTMLDivElement>,
-    ) => {
-      const [hostSidebarCounters, setHostSidebarCounters] = useAtom(
-        hostSidebarCountersAtom,
-        jotaiScope,
-      );
+// FIXME replace this with the implem from ColorPicker once it's merged
+const useOnClickOutside = (
+  ref: RefObject<HTMLElement>,
+  cb: (event: MouseEvent) => void,
+) => {
+  useEffect(() => {
+    const listener = (event: MouseEvent) => {
+      if (!ref.current) {
+        return;
+      }
 
-      const setAppState = useExcalidrawSetAppState();
+      if (
+        event.target instanceof Element &&
+        (ref.current.contains(event.target) ||
+          !document.body.contains(event.target))
+      ) {
+        return;
+      }
 
-      const [isDockedFallback, setIsDockedFallback] = useState(
-        docked ?? initialDockedState ?? false,
-      );
+      cb(event);
+    };
+    document.addEventListener("pointerdown", listener, false);
 
-      useLayoutEffect(() => {
-        if (docked === undefined) {
-          // ugly hack to get initial state out of AppState without subscribing
-          // to it as a whole (once we have granular subscriptions, we'll move
-          // to that)
-          //
-          // NOTE this means that is updated `state.isSidebarDocked` changes outside
-          // of this compoent, it won't be reflected here. Currently doesn't happen.
-          setAppState((state) => {
-            setIsDockedFallback(state.isSidebarDocked);
-            // bail from update
-            return null;
-          });
-        }
-      }, [setAppState, docked]);
-
-      useLayoutEffect(() => {
-        if (!__isInternal) {
-          setHostSidebarCounters((s) => ({
-            rendered: s.rendered + 1,
-            docked: isDockedFallback ? s.docked + 1 : s.docked,
-          }));
-          return () => {
-            setHostSidebarCounters((s) => ({
-              rendered: s.rendered - 1,
-              docked: isDockedFallback ? s.docked - 1 : s.docked,
-            }));
-          };
-        }
-      }, [__isInternal, setHostSidebarCounters, isDockedFallback]);
+    return () => {
+      document.removeEventListener("pointerdown", listener);
+    };
+  }, [ref, cb]);
+};
 
-      const onCloseRef = useRef(onClose);
-      onCloseRef.current = onClose;
+/**
+ * Flags whether the currently rendered Sidebar is docked or not, for use
+ * in upstream components that need to act on this (e.g. LayerUI to shift the
+ * UI). We use an atom because of potential host app sidebars (for the default
+ * sidebar we could just read from appState.defaultSidebarDockedPreference).
+ *
+ * Since we can only render one Sidebar at a time, we can use a simple flag.
+ */
+export const isSidebarDockedAtom = atom(false);
+
+export const SidebarInner = forwardRef(
+  (
+    {
+      name,
+      children,
+      onDock,
+      docked,
+      className,
+      ...rest
+    }: SidebarProps & Omit<React.RefAttributes<HTMLDivElement>, "onSelect">,
+    ref: React.ForwardedRef<HTMLDivElement>,
+  ) => {
+    if (process.env.NODE_ENV === "development" && onDock && docked == null) {
+      console.warn(
+        "Sidebar: `docked` must be set when `onDock` is supplied for the sidebar to be user-dockable. To hide this message, either pass `docked` or remove `onDock`",
+      );
+    }
 
-      useEffect(() => {
-        return () => {
-          onCloseRef.current?.();
-        };
-      }, []);
+    const setAppState = useExcalidrawSetAppState();
 
-      const headerPropsRef = useRef<SidebarPropsContextValue>({});
-      headerPropsRef.current.onClose = () => {
-        setAppState({ openSidebar: null });
+    const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom, jotaiScope);
+
+    useLayoutEffect(() => {
+      setIsSidebarDockedAtom(!!docked);
+      return () => {
+        setIsSidebarDockedAtom(false);
       };
-      headerPropsRef.current.onDock = (isDocked) => {
-        if (docked === undefined) {
-          setAppState({ isSidebarDocked: isDocked });
-          setIsDockedFallback(isDocked);
+    }, [setIsSidebarDockedAtom, docked]);
+
+    const headerPropsRef = useRef<SidebarPropsContextValue>(
+      {} as SidebarPropsContextValue,
+    );
+    headerPropsRef.current.onCloseRequest = () => {
+      setAppState({ openSidebar: null });
+    };
+    headerPropsRef.current.onDock = (isDocked) => onDock?.(isDocked);
+    // renew the ref object if the following props change since we want to
+    // rerender. We can't pass down as component props manually because
+    // the <Sidebar.Header/> can be rendered upstream.
+    headerPropsRef.current = updateObject(headerPropsRef.current, {
+      docked,
+      // explicit prop to rerender on update
+      shouldRenderDockButton: !!onDock && docked != null,
+    });
+
+    const islandRef = useRef<HTMLDivElement>(null);
+
+    useImperativeHandle(ref, () => {
+      return islandRef.current!;
+    });
+
+    const device = useDevice();
+
+    const closeLibrary = useCallback(() => {
+      const isDialogOpen = !!document.querySelector(".Dialog");
+
+      // Prevent closing if any dialog is open
+      if (isDialogOpen) {
+        return;
+      }
+      setAppState({ openSidebar: null });
+    }, [setAppState]);
+
+    useOnClickOutside(
+      islandRef,
+      useCallback(
+        (event) => {
+          // If click on the library icon, do nothing so that LibraryButton
+          // can toggle library menu
+          if ((event.target as Element).closest(".sidebar-trigger")) {
+            return;
+          }
+          if (!docked || !device.canDeviceFitSidebar) {
+            closeLibrary();
+          }
+        },
+        [closeLibrary, docked, device.canDeviceFitSidebar],
+      ),
+    );
+
+    useEffect(() => {
+      const handleKeyDown = (event: KeyboardEvent) => {
+        if (
+          event.key === KEYS.ESCAPE &&
+          (!docked || !device.canDeviceFitSidebar)
+        ) {
+          closeLibrary();
         }
-        onDock?.(isDocked);
       };
-      // renew the ref object if the following props change since we want to
-      // rerender. We can't pass down as component props manually because
-      // the <Sidebar.Header/> can be rendered upsream.
-      headerPropsRef.current = updateObject(headerPropsRef.current, {
-        docked: docked ?? isDockedFallback,
-        dockable,
-      });
-
-      if (hostSidebarCounters.rendered > 0 && __isInternal) {
-        return null;
+      document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
+      return () => {
+        document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
+      };
+    }, [closeLibrary, docked, device.canDeviceFitSidebar]);
+
+    return (
+      <Island
+        {...rest}
+        className={clsx("sidebar", { "sidebar--docked": docked }, className)}
+        ref={islandRef}
+      >
+        <SidebarPropsContext.Provider value={headerPropsRef.current}>
+          {children}
+        </SidebarPropsContext.Provider>
+      </Island>
+    );
+  },
+);
+SidebarInner.displayName = "SidebarInner";
+
+export const Sidebar = Object.assign(
+  forwardRef((props: SidebarProps, ref: React.ForwardedRef<HTMLDivElement>) => {
+    const appState = useExcalidrawAppState();
+
+    const { onStateChange } = props;
+
+    const refPrevOpenSidebar = useRef(appState.openSidebar);
+    useEffect(() => {
+      if (
+        // closing sidebar
+        ((!appState.openSidebar &&
+          refPrevOpenSidebar?.current?.name === props.name) ||
+          // opening current sidebar
+          (appState.openSidebar?.name === props.name &&
+            refPrevOpenSidebar?.current?.name !== props.name) ||
+          // switching tabs or switching to a different sidebar
+          refPrevOpenSidebar.current?.name === props.name) &&
+        appState.openSidebar !== refPrevOpenSidebar.current
+      ) {
+        onStateChange?.(
+          appState.openSidebar?.name !== props.name
+            ? null
+            : appState.openSidebar,
+        );
       }
+      refPrevOpenSidebar.current = appState.openSidebar;
+    }, [appState.openSidebar, onStateChange, props.name]);
 
-      return (
-        <Island
-          className={clsx(
-            "layer-ui__sidebar",
-            { "layer-ui__sidebar--docked": isDockedFallback },
-            className,
-          )}
-          ref={ref}
-        >
-          <SidebarPropsContext.Provider value={headerPropsRef.current}>
-            <SidebarHeaderComponents.Context>
-              <SidebarHeaderComponents.Component __isFallback />
-              {children}
-            </SidebarHeaderComponents.Context>
-          </SidebarPropsContext.Provider>
-        </Island>
-      );
-    },
-  ),
+    const [mounted, setMounted] = useState(false);
+    useLayoutEffect(() => {
+      setMounted(true);
+      return () => setMounted(false);
+    }, []);
+
+    // We want to render in the next tick (hence `mounted` flag) so that it's
+    // guaranteed to happen after unmount of the previous sidebar (in case the
+    // previous sidebar is mounted after the next one). This is necessary to
+    // prevent flicker of subcomponents that support fallbacks
+    // (e.g. SidebarHeader). This is because we're using flags to determine
+    // whether prefer the fallback component or not (otherwise both will render
+    // initially), and the flag won't be reset in time if the unmount order
+    // it not correct.
+    //
+    // Alternative, and more general solution would be to namespace the fallback
+    // HoC so that state is not shared between subcomponents when the wrapping
+    // component is of the same type (e.g. Sidebar -> SidebarHeader).
+    const shouldRender = mounted && appState.openSidebar?.name === props.name;
+
+    if (!shouldRender) {
+      return null;
+    }
+
+    return <SidebarInner {...props} ref={ref} key={props.name} />;
+  }),
   {
-    Header: SidebarHeaderComponents.Component,
+    Header: SidebarHeader,
+    TabTriggers: SidebarTabTriggers,
+    TabTrigger: SidebarTabTrigger,
+    Tabs: SidebarTabs,
+    Tab: SidebarTab,
+    Trigger: SidebarTrigger,
   },
 );
+Sidebar.displayName = "Sidebar";

+ 33 - 65
src/components/Sidebar/SidebarHeader.tsx

@@ -4,86 +4,54 @@ import { t } from "../../i18n";
 import { useDevice } from "../App";
 import { SidebarPropsContext } from "./common";
 import { CloseIcon, PinIcon } from "../icons";
-import { withUpstreamOverride } from "../hoc/withUpstreamOverride";
 import { Tooltip } from "../Tooltip";
+import { Button } from "../Button";
 
-export const SidebarDockButton = (props: {
-  checked: boolean;
-  onChange?(): void;
-}) => {
-  return (
-    <div className="layer-ui__sidebar-dock-button" data-testid="sidebar-dock">
-      <Tooltip label={t("labels.sidebarLock")}>
-        <label
-          className={clsx(
-            "ToolIcon ToolIcon__lock ToolIcon_type_floating",
-            `ToolIcon_size_medium`,
-          )}
-        >
-          <input
-            className="ToolIcon_type_checkbox"
-            type="checkbox"
-            onChange={props.onChange}
-            checked={props.checked}
-            aria-label={t("labels.sidebarLock")}
-          />{" "}
-          <div
-            className={clsx("Sidebar__pin-btn", {
-              "Sidebar__pin-btn--pinned": props.checked,
-            })}
-            tabIndex={0}
-          >
-            {PinIcon}
-          </div>{" "}
-        </label>{" "}
-      </Tooltip>
-    </div>
-  );
-};
-
-const _SidebarHeader: React.FC<{
+export const SidebarHeader = ({
+  children,
+  className,
+}: {
   children?: React.ReactNode;
   className?: string;
-}> = ({ children, className }) => {
+}) => {
   const device = useDevice();
   const props = useContext(SidebarPropsContext);
 
-  const renderDockButton = !!(device.canDeviceFitSidebar && props.dockable);
-  const renderCloseButton = !!props.onClose;
+  const renderDockButton = !!(
+    device.canDeviceFitSidebar && props.shouldRenderDockButton
+  );
 
   return (
     <div
-      className={clsx("layer-ui__sidebar__header", className)}
+      className={clsx("sidebar__header", className)}
       data-testid="sidebar-header"
     >
       {children}
-      {(renderDockButton || renderCloseButton) && (
-        <div className="layer-ui__sidebar__header__buttons">
-          {renderDockButton && (
-            <SidebarDockButton
-              checked={!!props.docked}
-              onChange={() => {
-                props.onDock?.(!props.docked);
-              }}
-            />
-          )}
-          {renderCloseButton && (
-            <button
-              data-testid="sidebar-close"
-              className="Sidebar__close-btn"
-              onClick={props.onClose}
-              aria-label={t("buttons.close")}
+      <div className="sidebar__header__buttons">
+        {renderDockButton && (
+          <Tooltip label={t("labels.sidebarLock")}>
+            <Button
+              onSelect={() => props.onDock?.(!props.docked)}
+              selected={!!props.docked}
+              className="sidebar__dock"
+              data-testid="sidebar-dock"
+              aria-label={t("labels.sidebarLock")}
             >
-              {CloseIcon}
-            </button>
-          )}
-        </div>
-      )}
+              {PinIcon}
+            </Button>
+          </Tooltip>
+        )}
+        <Button
+          data-testid="sidebar-close"
+          className="sidebar__close"
+          onSelect={props.onCloseRequest}
+          aria-label={t("buttons.close")}
+        >
+          {CloseIcon}
+        </Button>
+      </div>
     </div>
   );
 };
 
-const [Context, Component] = withUpstreamOverride(_SidebarHeader);
-
-/** @private */
-export const SidebarHeaderComponents = { Context, Component };
+SidebarHeader.displayName = "SidebarHeader";

+ 18 - 0
src/components/Sidebar/SidebarTab.tsx

@@ -0,0 +1,18 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import { SidebarTabName } from "../../types";
+
+export const SidebarTab = ({
+  tab,
+  children,
+  ...rest
+}: {
+  tab: SidebarTabName;
+  children: React.ReactNode;
+} & React.HTMLAttributes<HTMLDivElement>) => {
+  return (
+    <RadixTabs.Content {...rest} value={tab}>
+      {children}
+    </RadixTabs.Content>
+  );
+};
+SidebarTab.displayName = "SidebarTab";

+ 26 - 0
src/components/Sidebar/SidebarTabTrigger.tsx

@@ -0,0 +1,26 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import { SidebarTabName } from "../../types";
+
+export const SidebarTabTrigger = ({
+  children,
+  tab,
+  onSelect,
+  ...rest
+}: {
+  children: React.ReactNode;
+  tab: SidebarTabName;
+  onSelect?: React.ReactEventHandler<HTMLButtonElement> | undefined;
+} & Omit<React.HTMLAttributes<HTMLButtonElement>, "onSelect">) => {
+  return (
+    <RadixTabs.Trigger value={tab} asChild onSelect={onSelect}>
+      <button
+        type={"button"}
+        className={`excalidraw-button sidebar-tab-trigger`}
+        {...rest}
+      >
+        {children}
+      </button>
+    </RadixTabs.Trigger>
+  );
+};
+SidebarTabTrigger.displayName = "SidebarTabTrigger";

+ 16 - 0
src/components/Sidebar/SidebarTabTriggers.tsx

@@ -0,0 +1,16 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+
+export const SidebarTabTriggers = ({
+  children,
+  ...rest
+}: { children: React.ReactNode } & Omit<
+  React.RefAttributes<HTMLDivElement>,
+  "onSelect"
+>) => {
+  return (
+    <RadixTabs.List className="sidebar-triggers" {...rest}>
+      {children}
+    </RadixTabs.List>
+  );
+};
+SidebarTabTriggers.displayName = "SidebarTabTriggers";

+ 36 - 0
src/components/Sidebar/SidebarTabs.tsx

@@ -0,0 +1,36 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import { useUIAppState } from "../../context/ui-appState";
+import { useExcalidrawSetAppState } from "../App";
+
+export const SidebarTabs = ({
+  children,
+  ...rest
+}: {
+  children: React.ReactNode;
+} & Omit<React.RefAttributes<HTMLDivElement>, "onSelect">) => {
+  const appState = useUIAppState();
+  const setAppState = useExcalidrawSetAppState();
+
+  if (!appState.openSidebar) {
+    return null;
+  }
+
+  const { name } = appState.openSidebar;
+
+  return (
+    <RadixTabs.Root
+      className="sidebar-tabs-root"
+      value={appState.openSidebar.tab}
+      onValueChange={(tab) =>
+        setAppState((state) => ({
+          ...state,
+          openSidebar: { ...state.openSidebar, name, tab },
+        }))
+      }
+      {...rest}
+    >
+      {children}
+    </RadixTabs.Root>
+  );
+};
+SidebarTabs.displayName = "SidebarTabs";

+ 34 - 0
src/components/Sidebar/SidebarTrigger.scss

@@ -0,0 +1,34 @@
+@import "../../css/variables.module";
+
+.excalidraw {
+  .sidebar-trigger {
+    @include outlineButtonStyles;
+
+    background-color: var(--island-bg-color);
+
+    width: auto;
+    height: var(--lg-button-size);
+
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+
+    line-height: 0;
+
+    font-size: 0.75rem;
+    letter-spacing: 0.4px;
+
+    svg {
+      width: var(--lg-icon-size);
+      height: var(--lg-icon-size);
+    }
+  }
+
+  .default-sidebar-trigger .sidebar-trigger__label {
+    display: none;
+
+    @media screen and (min-width: 1024px) {
+      display: block;
+    }
+  }
+}

+ 45 - 0
src/components/Sidebar/SidebarTrigger.tsx

@@ -0,0 +1,45 @@
+import { useExcalidrawSetAppState, useExcalidrawAppState } from "../App";
+import { SidebarTriggerProps } from "./common";
+
+import "./SidebarTrigger.scss";
+import clsx from "clsx";
+
+export const SidebarTrigger = ({
+  name,
+  tab,
+  icon,
+  title,
+  children,
+  onToggle,
+  className,
+  style,
+}: SidebarTriggerProps) => {
+  const setAppState = useExcalidrawSetAppState();
+  // TODO replace with sidebar context
+  const appState = useExcalidrawAppState();
+
+  return (
+    <label title={title}>
+      <input
+        className="ToolIcon_type_checkbox"
+        type="checkbox"
+        onChange={(event) => {
+          document
+            .querySelector(".layer-ui__wrapper")
+            ?.classList.remove("animate");
+          const isOpen = event.target.checked;
+          setAppState({ openSidebar: isOpen ? { name, tab } : null });
+          onToggle?.(isOpen);
+        }}
+        checked={appState.openSidebar?.name === name}
+        aria-label={title}
+        aria-keyshortcuts="0"
+      />
+      <div className={clsx("sidebar-trigger", className)} style={style}>
+        {icon && <div>{icon}</div>}
+        {children && <div className="sidebar-trigger__label">{children}</div>}
+      </div>
+    </label>
+  );
+};
+SidebarTrigger.displayName = "SidebarTrigger";

+ 26 - 8
src/components/Sidebar/common.ts

@@ -1,23 +1,41 @@
 import React from "react";
+import { AppState, SidebarName, SidebarTabName } from "../../types";
+
+export type SidebarTriggerProps = {
+  name: SidebarName;
+  tab?: SidebarTabName;
+  icon?: JSX.Element;
+  children?: React.ReactNode;
+  title?: string;
+  className?: string;
+  onToggle?: (open: boolean) => void;
+  style?: React.CSSProperties;
+};
 
 export type SidebarProps<P = {}> = {
+  name: SidebarName;
   children: React.ReactNode;
   /**
-   * Called on sidebar close (either by user action or by the editor).
+   * Called on sidebar open/close or tab change.
+   */
+  onStateChange?: (state: AppState["openSidebar"]) => void;
+  /**
+   * supply alongside `docked` prop in order to make the Sidebar user-dockable
    */
-  onClose?: () => void | boolean;
-  /** if not supplied, sidebar won't be dockable */
   onDock?: (docked: boolean) => void;
   docked?: boolean;
-  initialDockedState?: boolean;
-  dockable?: boolean;
   className?: string;
+  // NOTE sidebars we use internally inside the editor must have this flag set.
+  // It indicates that this sidebar should have lower precedence over host
+  // sidebars, if both are open.
+  /** @private internal */
+  __fallback?: boolean;
 } & P;
 
 export type SidebarPropsContextValue = Pick<
   SidebarProps,
-  "onClose" | "onDock" | "docked" | "dockable"
->;
+  "onDock" | "docked"
+> & { onCloseRequest: () => void; shouldRenderDockButton: boolean };
 
 export const SidebarPropsContext =
-  React.createContext<SidebarPropsContextValue>({});
+  React.createContext<SidebarPropsContextValue>({} as SidebarPropsContextValue);

+ 0 - 32
src/components/context/tunnels.ts

@@ -1,32 +0,0 @@
-import React from "react";
-import tunnel from "@dwelle/tunnel-rat";
-
-type Tunnel = ReturnType<typeof tunnel>;
-
-type TunnelsContextValue = {
-  mainMenuTunnel: Tunnel;
-  welcomeScreenMenuHintTunnel: Tunnel;
-  welcomeScreenToolbarHintTunnel: Tunnel;
-  welcomeScreenHelpHintTunnel: Tunnel;
-  welcomeScreenCenterTunnel: Tunnel;
-  footerCenterTunnel: Tunnel;
-  jotaiScope: symbol;
-};
-
-export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
-
-export const useTunnels = () => React.useContext(TunnelsContext);
-
-export const useInitializeTunnels = () => {
-  return React.useMemo((): TunnelsContextValue => {
-    return {
-      mainMenuTunnel: tunnel(),
-      welcomeScreenMenuHintTunnel: tunnel(),
-      welcomeScreenToolbarHintTunnel: tunnel(),
-      welcomeScreenHelpHintTunnel: tunnel(),
-      welcomeScreenCenterTunnel: tunnel(),
-      footerCenterTunnel: tunnel(),
-      jotaiScope: Symbol(),
-    };
-  }, []);
-};

+ 2 - 2
src/components/dropdownMenu/DropdownMenuContent.tsx

@@ -1,4 +1,4 @@
-import { useOutsideClickHook } from "../../hooks/useOutsideClick";
+import { useOutsideClick } from "../../hooks/useOutsideClick";
 import { Island } from "../Island";
 
 import { useDevice } from "../App";
@@ -24,7 +24,7 @@ const MenuContent = ({
   style?: React.CSSProperties;
 }) => {
   const device = useDevice();
-  const menuRef = useOutsideClickHook(() => {
+  const menuRef = useOutsideClick(() => {
     onClickOutside?.();
   });
 

+ 4 - 4
src/components/footer/Footer.tsx

@@ -9,7 +9,7 @@ import {
   ZoomActions,
 } from "../Actions";
 import { useDevice } from "../App";
-import { useTunnels } from "../context/tunnels";
+import { useTunnels } from "../../context/tunnels";
 import { HelpButton } from "../HelpButton";
 import { Section } from "../Section";
 import Stack from "../Stack";
@@ -25,7 +25,7 @@ const Footer = ({
   showExitZenModeBtn: boolean;
   renderWelcomeScreen: boolean;
 }) => {
-  const { footerCenterTunnel, welcomeScreenHelpHintTunnel } = useTunnels();
+  const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels();
 
   const device = useDevice();
   const showFinalize =
@@ -70,14 +70,14 @@ const Footer = ({
           </Section>
         </Stack.Col>
       </div>
-      <footerCenterTunnel.Out />
+      <FooterCenterTunnel.Out />
       <div
         className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", {
           "transition-right disable-pointerEvents": appState.zenModeEnabled,
         })}
       >
         <div style={{ position: "relative" }}>
-          {renderWelcomeScreen && <welcomeScreenHelpHintTunnel.Out />}
+          {renderWelcomeScreen && <WelcomeScreenHelpHintTunnel.Out />}
           <HelpButton
             onClick={() => actionManager.executeAction(actionShortcuts)}
           />

+ 4 - 4
src/components/footer/FooterCenter.tsx

@@ -1,13 +1,13 @@
 import clsx from "clsx";
 import { useExcalidrawAppState } from "../App";
-import { useTunnels } from "../context/tunnels";
+import { useTunnels } from "../../context/tunnels";
 import "./FooterCenter.scss";
 
 const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
-  const { footerCenterTunnel } = useTunnels();
+  const { FooterCenterTunnel } = useTunnels();
   const appState = useExcalidrawAppState();
   return (
-    <footerCenterTunnel.In>
+    <FooterCenterTunnel.In>
       <div
         className={clsx("footer-center zen-mode-transition", {
           "layer-ui__wrapper__footer-left--transition-bottom":
@@ -16,7 +16,7 @@ const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
       >
         {children}
       </div>
-    </footerCenterTunnel.In>
+    </FooterCenterTunnel.In>
   );
 };
 

+ 20 - 6
src/components/hoc/withInternalFallback.tsx

@@ -1,32 +1,46 @@
 import { atom, useAtom } from "jotai";
 import React, { useLayoutEffect } from "react";
-import { useTunnels } from "../context/tunnels";
+import { useTunnels } from "../../context/tunnels";
 
 export const withInternalFallback = <P,>(
   componentName: string,
   Component: React.FC<P>,
 ) => {
-  const counterAtom = atom(0);
+  const renderAtom = atom(0);
   // flag set on initial render to tell the fallback component to skip the
   // render until mount counter are initialized. This is because the counter
   // is initialized in an effect, and thus we could end rendering both
   // components at the same time until counter is initialized.
   let preferHost = false;
 
+  let counter = 0;
+
   const WrapperComponent: React.FC<
     P & {
       __fallback?: boolean;
     }
   > = (props) => {
     const { jotaiScope } = useTunnels();
-    const [counter, setCounter] = useAtom(counterAtom, jotaiScope);
+    const [, setRender] = useAtom(renderAtom, jotaiScope);
 
     useLayoutEffect(() => {
-      setCounter((counter) => counter + 1);
+      setRender((c) => {
+        const next = c + 1;
+        counter = next;
+
+        return next;
+      });
       return () => {
-        setCounter((counter) => counter - 1);
+        setRender((c) => {
+          const next = c - 1;
+          counter = next;
+          if (!next) {
+            preferHost = false;
+          }
+          return next;
+        });
       };
-    }, [setCounter]);
+    }, [setRender]);
 
     if (!props.__fallback) {
       preferHost = true;

+ 0 - 63
src/components/hoc/withUpstreamOverride.tsx

@@ -1,63 +0,0 @@
-import React, {
-  useMemo,
-  useContext,
-  useLayoutEffect,
-  useState,
-  createContext,
-} from "react";
-
-export const withUpstreamOverride = <P,>(Component: React.ComponentType<P>) => {
-  type ContextValue = [boolean, React.Dispatch<React.SetStateAction<boolean>>];
-
-  const DefaultComponentContext = createContext<ContextValue>([
-    false,
-    () => {},
-  ]);
-
-  const ComponentContext: React.FC<{ children: React.ReactNode }> = ({
-    children,
-  }) => {
-    const [isRenderedUpstream, setIsRenderedUpstream] = useState(false);
-    const contextValue: ContextValue = useMemo(
-      () => [isRenderedUpstream, setIsRenderedUpstream],
-      [isRenderedUpstream],
-    );
-
-    return (
-      <DefaultComponentContext.Provider value={contextValue}>
-        {children}
-      </DefaultComponentContext.Provider>
-    );
-  };
-
-  const DefaultComponent = (
-    props: P & {
-      // indicates whether component should render when not rendered upstream
-      /** @private internal */
-      __isFallback?: boolean;
-    },
-  ) => {
-    const [isRenderedUpstream, setIsRenderedUpstream] = useContext(
-      DefaultComponentContext,
-    );
-
-    useLayoutEffect(() => {
-      if (!props.__isFallback) {
-        setIsRenderedUpstream(true);
-        return () => setIsRenderedUpstream(false);
-      }
-    }, [props.__isFallback, setIsRenderedUpstream]);
-
-    if (props.__isFallback && isRenderedUpstream) {
-      return null;
-    }
-
-    return <Component {...props} />;
-  };
-  if (Component.name) {
-    DefaultComponent.displayName = `${Component.name}_upstreamOverrideWrapper`;
-    ComponentContext.displayName = `${Component.name}_upstreamOverrideContextWrapper`;
-  }
-
-  return [ComponentContext, DefaultComponent] as const;
-};

+ 4 - 4
src/components/main-menu/MainMenu.tsx

@@ -13,7 +13,7 @@ import { t } from "../../i18n";
 import { HamburgerMenuIcon } from "../icons";
 import { withInternalFallback } from "../hoc/withInternalFallback";
 import { composeEventHandlers } from "../../utils";
-import { useTunnels } from "../context/tunnels";
+import { useTunnels } from "../../context/tunnels";
 
 const MainMenu = Object.assign(
   withInternalFallback(
@@ -28,7 +28,7 @@ const MainMenu = Object.assign(
        */
       onSelect?: (event: Event) => void;
     }) => {
-      const { mainMenuTunnel } = useTunnels();
+      const { MainMenuTunnel } = useTunnels();
       const device = useDevice();
       const appState = useExcalidrawAppState();
       const setAppState = useExcalidrawSetAppState();
@@ -37,7 +37,7 @@ const MainMenu = Object.assign(
         : () => setAppState({ openMenu: null });
 
       return (
-        <mainMenuTunnel.In>
+        <MainMenuTunnel.In>
           <DropdownMenu open={appState.openMenu === "canvas"}>
             <DropdownMenu.Trigger
               onToggle={() => {
@@ -66,7 +66,7 @@ const MainMenu = Object.assign(
               )}
             </DropdownMenu.Content>
           </DropdownMenu>
-        </mainMenuTunnel.In>
+        </MainMenuTunnel.In>
       );
     },
   ),

+ 4 - 4
src/components/welcome-screen/WelcomeScreen.Center.tsx

@@ -6,7 +6,7 @@ import {
   useExcalidrawActionManager,
   useExcalidrawAppState,
 } from "../App";
-import { useTunnels } from "../context/tunnels";
+import { useTunnels } from "../../context/tunnels";
 import { ExcalLogo, HelpIcon, LoadIcon, usersIcon } from "../icons";
 
 const WelcomeScreenMenuItemContent = ({
@@ -89,9 +89,9 @@ const WelcomeScreenMenuItemLink = ({
 WelcomeScreenMenuItemLink.displayName = "WelcomeScreenMenuItemLink";
 
 const Center = ({ children }: { children?: React.ReactNode }) => {
-  const { welcomeScreenCenterTunnel } = useTunnels();
+  const { WelcomeScreenCenterTunnel } = useTunnels();
   return (
-    <welcomeScreenCenterTunnel.In>
+    <WelcomeScreenCenterTunnel.In>
       <div className="welcome-screen-center">
         {children || (
           <>
@@ -104,7 +104,7 @@ const Center = ({ children }: { children?: React.ReactNode }) => {
           </>
         )}
       </div>
-    </welcomeScreenCenterTunnel.In>
+    </WelcomeScreenCenterTunnel.In>
   );
 };
 Center.displayName = "Center";

+ 10 - 10
src/components/welcome-screen/WelcomeScreen.Hints.tsx

@@ -1,5 +1,5 @@
 import { t } from "../../i18n";
-import { useTunnels } from "../context/tunnels";
+import { useTunnels } from "../../context/tunnels";
 import {
   WelcomeScreenHelpArrow,
   WelcomeScreenMenuArrow,
@@ -7,44 +7,44 @@ import {
 } from "../icons";
 
 const MenuHint = ({ children }: { children?: React.ReactNode }) => {
-  const { welcomeScreenMenuHintTunnel } = useTunnels();
+  const { WelcomeScreenMenuHintTunnel } = useTunnels();
   return (
-    <welcomeScreenMenuHintTunnel.In>
+    <WelcomeScreenMenuHintTunnel.In>
       <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
         {WelcomeScreenMenuArrow}
         <div className="welcome-screen-decor-hint__label">
           {children || t("welcomeScreen.defaults.menuHint")}
         </div>
       </div>
-    </welcomeScreenMenuHintTunnel.In>
+    </WelcomeScreenMenuHintTunnel.In>
   );
 };
 MenuHint.displayName = "MenuHint";
 
 const ToolbarHint = ({ children }: { children?: React.ReactNode }) => {
-  const { welcomeScreenToolbarHintTunnel } = useTunnels();
+  const { WelcomeScreenToolbarHintTunnel } = useTunnels();
   return (
-    <welcomeScreenToolbarHintTunnel.In>
+    <WelcomeScreenToolbarHintTunnel.In>
       <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
         <div className="welcome-screen-decor-hint__label">
           {children || t("welcomeScreen.defaults.toolbarHint")}
         </div>
         {WelcomeScreenTopToolbarArrow}
       </div>
-    </welcomeScreenToolbarHintTunnel.In>
+    </WelcomeScreenToolbarHintTunnel.In>
   );
 };
 ToolbarHint.displayName = "ToolbarHint";
 
 const HelpHint = ({ children }: { children?: React.ReactNode }) => {
-  const { welcomeScreenHelpHintTunnel } = useTunnels();
+  const { WelcomeScreenHelpHintTunnel } = useTunnels();
   return (
-    <welcomeScreenHelpHintTunnel.In>
+    <WelcomeScreenHelpHintTunnel.In>
       <div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
         <div>{children || t("welcomeScreen.defaults.helpHint")}</div>
         {WelcomeScreenHelpArrow}
       </div>
-    </welcomeScreenHelpHintTunnel.In>
+    </WelcomeScreenHelpHintTunnel.In>
   );
 };
 HelpHint.displayName = "HelpHint";

+ 7 - 0
src/constants.ts

@@ -275,3 +275,10 @@ export const DEFAULT_ELEMENT_PROPS: {
   opacity: 100,
   locked: false,
 };
+
+export const LIBRARY_SIDEBAR_TAB = "library";
+
+export const DEFAULT_SIDEBAR = {
+  name: "default",
+  defaultTab: LIBRARY_SIDEBAR_TAB,
+} as const;

+ 36 - 0
src/context/tunnels.ts

@@ -0,0 +1,36 @@
+import React from "react";
+import tunnel from "tunnel-rat";
+
+export type Tunnel = ReturnType<typeof tunnel>;
+
+type TunnelsContextValue = {
+  MainMenuTunnel: Tunnel;
+  WelcomeScreenMenuHintTunnel: Tunnel;
+  WelcomeScreenToolbarHintTunnel: Tunnel;
+  WelcomeScreenHelpHintTunnel: Tunnel;
+  WelcomeScreenCenterTunnel: Tunnel;
+  FooterCenterTunnel: Tunnel;
+  DefaultSidebarTriggerTunnel: Tunnel;
+  DefaultSidebarTabTriggersTunnel: Tunnel;
+  jotaiScope: symbol;
+};
+
+export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
+
+export const useTunnels = () => React.useContext(TunnelsContext);
+
+export const useInitializeTunnels = () => {
+  return React.useMemo((): TunnelsContextValue => {
+    return {
+      MainMenuTunnel: tunnel(),
+      WelcomeScreenMenuHintTunnel: tunnel(),
+      WelcomeScreenToolbarHintTunnel: tunnel(),
+      WelcomeScreenHelpHintTunnel: tunnel(),
+      WelcomeScreenCenterTunnel: tunnel(),
+      FooterCenterTunnel: tunnel(),
+      DefaultSidebarTriggerTunnel: tunnel(),
+      DefaultSidebarTabTriggersTunnel: tunnel(),
+      jotaiScope: Symbol(),
+    };
+  }, []);
+};

+ 5 - 0
src/context/ui-appState.ts

@@ -0,0 +1,5 @@
+import React from "react";
+import { AppState } from "../types";
+
+export const UIAppStateContext = React.createContext<AppState>(null!);
+export const useUIAppState = () => React.useContext(UIAppStateContext);

+ 1 - 1
src/css/styles.scss

@@ -567,7 +567,7 @@
       border-radius: 0;
     }
 
-    .library-button {
+    .default-sidebar-trigger {
       border: 0;
     }
   }

+ 6 - 0
src/css/theme.scss

@@ -78,10 +78,13 @@
 
   --color-selection: #6965db;
 
+  --color-icon-white: #{$oc-white};
+
   --color-primary: #6965db;
   --color-primary-darker: #5b57d1;
   --color-primary-darkest: #4a47b1;
   --color-primary-light: #e3e2fe;
+  --color-primary-light-darker: #d7d5ff;
 
   --color-gray-10: #f5f5f5;
   --color-gray-20: #ebebeb;
@@ -161,10 +164,13 @@
     // will be inverted to a lighter color.
     --color-selection: #3530c4;
 
+    --color-icon-white: var(--color-gray-90);
+
     --color-primary: #a8a5ff;
     --color-primary-darker: #b2aeff;
     --color-primary-darkest: #beb9ff;
     --color-primary-light: #4f4d6f;
+    --color-primary-light-darker: #43415e;
 
     --color-text-warning: var(--color-gray-80);
 

+ 14 - 4
src/css/variables.module.scss

@@ -72,7 +72,14 @@
 
   &:hover {
     background-color: var(--button-hover-bg, var(--island-bg-color));
-    border-color: var(--button-hover-border, var(--default-border-color));
+    border-color: var(
+      --button-hover-border,
+      var(--button-border, var(--default-border-color))
+    );
+    color: var(
+      --button-hover-color,
+      var(--button-color, var(--text-primary-color, inherit))
+    );
   }
 
   &:active {
@@ -81,11 +88,14 @@
   }
 
   &.active {
-    background-color: var(--color-primary-light);
-    border-color: var(--color-primary-light);
+    background-color: var(--button-selected-bg, var(--color-primary-light));
+    border-color: var(--button-selected-border, var(--color-primary-light));
 
     &:hover {
-      background-color: var(--color-primary-light);
+      background-color: var(
+        --button-selected-hover-bg,
+        var(--color-primary-light)
+      );
     }
 
     svg {

+ 18 - 4
src/data/library.ts

@@ -14,7 +14,14 @@ import { getCommonBoundingBox } from "../element/bounds";
 import { AbortError } from "../errors";
 import { t } from "../i18n";
 import { useEffect, useRef } from "react";
-import { URL_HASH_KEYS, URL_QUERY_KEYS, APP_NAME, EVENT } from "../constants";
+import {
+  URL_HASH_KEYS,
+  URL_QUERY_KEYS,
+  APP_NAME,
+  EVENT,
+  DEFAULT_SIDEBAR,
+  LIBRARY_SIDEBAR_TAB,
+} from "../constants";
 
 export const libraryItemsAtom = atom<{
   status: "loading" | "loaded";
@@ -148,7 +155,9 @@ class Library {
     defaultStatus?: "unpublished" | "published";
   }): Promise<LibraryItems> => {
     if (openLibraryMenu) {
-      this.app.setState({ openSidebar: "library" });
+      this.app.setState({
+        openSidebar: { name: DEFAULT_SIDEBAR.name, tab: LIBRARY_SIDEBAR_TAB },
+      });
     }
 
     return this.setLibrary(() => {
@@ -174,6 +183,13 @@ class Library {
               }),
             )
           ) {
+            if (prompt) {
+              // focus container if we've prompted. We focus conditionally
+              // lest `props.autoFocus` is disabled (in which case we should
+              // focus only on user action such as prompt confirm)
+              this.app.focusContainer();
+            }
+
             if (merge) {
               resolve(mergeLibraryItems(this.lastLibraryItems, nextItems));
             } else {
@@ -186,8 +202,6 @@ class Library {
           reject(error);
         }
       });
-    }).finally(() => {
-      this.app.focusContainer();
     });
   };
 

+ 12 - 20
src/data/restore.ts

@@ -27,6 +27,7 @@ import {
   PRECEDING_ELEMENT_KEY,
   FONT_FAMILY,
   ROUNDNESS,
+  DEFAULT_SIDEBAR,
 } from "../constants";
 import { getDefaultAppState } from "../appState";
 import { LinearElementEditor } from "../element/linearElementEditor";
@@ -431,21 +432,15 @@ const LegacyAppStateMigrations: {
     defaultAppState: ReturnType<typeof getDefaultAppState>,
   ) => [LegacyAppState[K][1], AppState[LegacyAppState[K][1]]];
 } = {
-  isLibraryOpen: (appState, defaultAppState) => {
+  isSidebarDocked: (appState, defaultAppState) => {
     return [
-      "openSidebar",
-      "isLibraryOpen" in appState
-        ? appState.isLibraryOpen
-          ? "library"
-          : null
-        : coalesceAppStateValue("openSidebar", appState, defaultAppState),
-    ];
-  },
-  isLibraryMenuDocked: (appState, defaultAppState) => {
-    return [
-      "isSidebarDocked",
-      appState.isLibraryMenuDocked ??
-        coalesceAppStateValue("isSidebarDocked", appState, defaultAppState),
+      "defaultSidebarDockedPreference",
+      appState.isSidebarDocked ??
+        coalesceAppStateValue(
+          "defaultSidebarDockedPreference",
+          appState,
+          defaultAppState,
+        ),
     ];
   },
 };
@@ -517,13 +512,10 @@ export const restoreAppState = (
         : appState.zoom?.value
         ? appState.zoom
         : defaultAppState.zoom,
-    // when sidebar docked and user left it open in last session,
-    // keep it open. If not docked, keep it closed irrespective of last state.
     openSidebar:
-      nextAppState.openSidebar === "library"
-        ? nextAppState.isSidebarDocked
-          ? "library"
-          : null
+      // string (legacy)
+      typeof (appState.openSidebar as any as string) === "string"
+        ? { name: DEFAULT_SIDEBAR.name }
         : nextAppState.openSidebar,
   };
 };

+ 2 - 4
src/data/types.ts

@@ -25,10 +25,8 @@ export interface ExportedDataState {
  * Don't consume on its own.
  */
 export type LegacyAppState = {
-  /** @deprecated #5663 TODO remove 22-12-15 */
-  isLibraryOpen: [boolean, "openSidebar"];
-  /** @deprecated #5663 TODO remove 22-12-15 */
-  isLibraryMenuDocked: [boolean, "isSidebarDocked"];
+  /** @deprecated #6213 TODO remove 23-06-01 */
+  isSidebarDocked: [boolean, "defaultSidebarDockedPreference"];
 };
 
 export interface ImportedDataState {

+ 1 - 1
src/hooks/useOutsideClick.ts

@@ -1,6 +1,6 @@
 import { useEffect, useRef } from "react";
 
-export const useOutsideClickHook = (handler: (event: Event) => void) => {
+export const useOutsideClick = (handler: (event: Event) => void) => {
   const ref = useRef(null);
 
   useEffect(

+ 16 - 0
src/packages/excalidraw/CHANGELOG.md

@@ -11,6 +11,22 @@ The change should be grouped under one of the below section and must contain PR
 Please add the latest change on the top under the correct section.
 -->
 
+## Unreleased
+
+### Features
+
+- Sidebar component now supports tabs — for more detailed description of new behavior and breaking changes, see the linked PR. [#6213](https://github.com/excalidraw/excalidraw/pull/6213)
+- Exposed `DefaultSidebar` component to allow modifying the default sidebar, such as adding custom tabs to it. [#6213](https://github.com/excalidraw/excalidraw/pull/6213)
+
+  #### BREAKING CHANGES
+
+  - `props.renderSidebar` is removed in favor of rendering as `children`.
+  - `appState.isSidebarDocked` replaced with `appState.defaultSidebarDockedPreference` with slightly different semantics, and relating only to the default sidebar. You need to handle `docked` state for your custom sidebars yourself.
+  - Sidebar `props.dockable` is removed. To indicate dockability, supply `props.onDock()` alongside setting `props.docked`.
+  - `Sidebar.Header` is no longer rendered by default. You need to render it yourself.
+  - `props.onClose` replaced with `props.onStateChange`.
+  - `restore()`/`restoreAppState()` now retains `appState.openSidebar` regardless of docked state.
+
 ## 0.15.2 (2023-04-20)
 
 ### Docs

+ 24 - 27
src/packages/excalidraw/example/App.tsx

@@ -494,15 +494,6 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
     );
   };
 
-  const renderSidebar = () => {
-    return (
-      <Sidebar>
-        <Sidebar.Header>Custom header!</Sidebar.Header>
-        Custom sidebar!
-      </Sidebar>
-    );
-  };
-
   const renderMenu = () => {
     return (
       <MainMenu>
@@ -668,23 +659,6 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
           </div>
         </div>
         <div className="excalidraw-wrapper">
-          <div
-            style={{
-              position: "absolute",
-              left: "50%",
-              bottom: "20px",
-              display: "flex",
-              zIndex: 9999999999999999,
-              padding: "5px 10px",
-              transform: "translateX(-50%)",
-              background: "rgba(255, 255, 255, 0.8)",
-              gap: "1rem",
-            }}
-          >
-            <button onClick={() => excalidrawAPI?.toggleMenu("customSidebar")}>
-              Toggle Custom Sidebar
-            </button>
-          </div>
           <Excalidraw
             ref={(api: ExcalidrawImperativeAPI) => setExcalidrawAPI(api)}
             initialData={initialStatePromiseRef.current.promise}
@@ -706,7 +680,6 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
             onLinkOpen={onLinkOpen}
             onPointerDown={onPointerDown}
             onScrollChange={rerenderCommentIcons}
-            renderSidebar={renderSidebar}
           >
             {excalidrawAPI && (
               <Footer>
@@ -714,6 +687,30 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
               </Footer>
             )}
             <WelcomeScreen />
+            <Sidebar name="custom">
+              <Sidebar.Tabs>
+                <Sidebar.Header />
+                <Sidebar.Tab tab="one">Tab one!</Sidebar.Tab>
+                <Sidebar.Tab tab="two">Tab two!</Sidebar.Tab>
+                <Sidebar.TabTriggers>
+                  <Sidebar.TabTrigger tab="one">One</Sidebar.TabTrigger>
+                  <Sidebar.TabTrigger tab="two">Two</Sidebar.TabTrigger>
+                </Sidebar.TabTriggers>
+              </Sidebar.Tabs>
+            </Sidebar>
+            <Sidebar.Trigger
+              name="custom"
+              tab="one"
+              style={{
+                position: "absolute",
+                left: "50%",
+                transform: "translateX(-50%)",
+                bottom: "20px",
+                zIndex: 9999999999999999,
+              }}
+            >
+              Toggle Custom Sidebar
+            </Sidebar.Trigger>
             {renderMenu()}
           </Excalidraw>
           {Object.keys(commentIcons || []).length > 0 && renderCommentIcons()}

+ 4 - 2
src/packages/excalidraw/index.tsx

@@ -24,7 +24,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
     isCollaborating = false,
     onPointerUpdate,
     renderTopRightUI,
-    renderSidebar,
     langCode = defaultLang.code,
     viewModeEnabled,
     zenModeEnabled,
@@ -47,6 +46,8 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
 
   const canvasActions = props.UIOptions?.canvasActions;
 
+  // FIXME normalize/set defaults in parent component so that the memo resolver
+  // compares the same values
   const UIOptions: AppProps["UIOptions"] = {
     ...props.UIOptions,
     canvasActions: {
@@ -114,7 +115,6 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
           onLinkOpen={onLinkOpen}
           onPointerDown={onPointerDown}
           onScrollChange={onScrollChange}
-          renderSidebar={renderSidebar}
         >
           {children}
         </App>
@@ -245,3 +245,5 @@ export { MainMenu };
 export { useDevice } from "../../components/App";
 export { WelcomeScreen };
 export { LiveCollaborationTrigger };
+
+export { DefaultSidebar } from "../../components/DefaultSidebar";

+ 17 - 17
src/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -284,6 +284,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -300,7 +301,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -464,6 +464,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -480,7 +481,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -650,6 +650,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -666,7 +667,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -1005,6 +1005,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -1021,7 +1022,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -1360,6 +1360,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -1376,7 +1377,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -1546,6 +1546,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -1562,7 +1563,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -1768,6 +1768,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -1784,7 +1785,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -2053,6 +2053,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -2069,7 +2070,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -2426,6 +2426,7 @@ Object {
   "currentItemStrokeWidth": 2,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -2442,7 +2443,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -3273,6 +3273,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -3289,7 +3290,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -3628,6 +3628,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -3644,7 +3645,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -3983,6 +3983,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -3999,7 +4000,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -4681,6 +4681,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -4697,7 +4698,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -5231,6 +5231,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -5247,7 +5248,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -5712,6 +5712,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -5728,7 +5729,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -6080,6 +6080,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -6096,7 +6097,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -6426,6 +6426,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -6442,7 +6443,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",

+ 53 - 53
src/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -25,6 +25,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -41,7 +42,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -561,6 +561,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -577,7 +578,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -1103,6 +1103,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": "id10",
@@ -1119,7 +1120,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -2010,6 +2010,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -2026,7 +2027,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -2240,6 +2240,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -2256,7 +2257,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -2773,6 +2773,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -2789,7 +2790,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -3062,6 +3062,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -3078,7 +3079,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -3246,6 +3246,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -3262,7 +3263,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -3762,6 +3762,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -3778,7 +3779,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -4030,6 +4030,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -4046,7 +4047,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -4260,6 +4260,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -4276,7 +4277,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -4536,6 +4536,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -4552,7 +4553,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -4824,6 +4824,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -4840,7 +4841,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -5242,6 +5242,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "down",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": Object {
     "angle": 0,
     "backgroundColor": "transparent",
@@ -5285,7 +5286,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -5583,6 +5583,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": Object {
     "angle": 0,
     "backgroundColor": "transparent",
@@ -5626,7 +5627,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -5897,6 +5897,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "down",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": Object {
     "angle": 0,
     "backgroundColor": "transparent",
@@ -5940,7 +5941,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -6135,6 +6135,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -6151,7 +6152,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -6321,6 +6321,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": "id3",
@@ -6337,7 +6338,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -6849,6 +6849,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -6865,7 +6866,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -7214,6 +7214,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -7230,7 +7231,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -9566,6 +9566,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -9582,7 +9583,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -9985,6 +9985,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -10001,7 +10002,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -10274,6 +10274,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -10290,7 +10291,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -10522,6 +10522,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -10538,7 +10539,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -10843,6 +10843,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -10859,7 +10860,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -11027,6 +11027,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -11043,7 +11044,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -11211,6 +11211,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -11227,7 +11228,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -11395,6 +11395,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -11411,7 +11412,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -11632,6 +11632,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -11648,7 +11649,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -11869,6 +11869,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -11885,7 +11886,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -12097,6 +12097,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -12113,7 +12114,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -12334,6 +12334,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -12350,7 +12351,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -12518,6 +12518,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -12534,7 +12535,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -12755,6 +12755,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -12771,7 +12772,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -12939,6 +12939,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -12955,7 +12956,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -13167,6 +13167,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -13183,7 +13184,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -13351,6 +13351,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -13367,7 +13368,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -14190,6 +14190,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -14206,7 +14207,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -14479,6 +14479,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "down",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -14495,7 +14496,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "touch",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -14590,6 +14590,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -14606,7 +14607,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -14699,6 +14699,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -14715,7 +14716,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -14886,6 +14886,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -14902,7 +14903,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -15254,6 +15254,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -15270,7 +15271,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -15885,6 +15885,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -15901,7 +15902,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -16111,6 +16111,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -16127,7 +16128,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -17074,6 +17074,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -17090,7 +17091,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -17183,6 +17183,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": "id3",
@@ -17199,7 +17200,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -18042,6 +18042,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "down",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": Object {
     "angle": 0,
     "backgroundColor": "transparent",
@@ -18085,7 +18086,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -18514,6 +18514,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "down",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": Object {
     "angle": 0,
     "backgroundColor": "transparent",
@@ -18557,7 +18558,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -18855,6 +18855,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "down",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -18871,7 +18872,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "touch",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -18966,6 +18966,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -18982,7 +18983,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -19537,6 +19537,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -19553,7 +19554,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",
@@ -19646,6 +19646,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -19662,7 +19663,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "Untitled-201933152653",

+ 24 - 1
src/tests/data/restore.test.ts

@@ -10,7 +10,7 @@ import { API } from "../helpers/api";
 import { getDefaultAppState } from "../../appState";
 import { ImportedDataState } from "../../data/types";
 import { NormalizedZoomValue } from "../../types";
-import { FONT_FAMILY, ROUNDNESS } from "../../constants";
+import { DEFAULT_SIDEBAR, FONT_FAMILY, ROUNDNESS } from "../../constants";
 import { newElementWith } from "../../element/mutateElement";
 
 describe("restoreElements", () => {
@@ -453,6 +453,29 @@ describe("restoreAppState", () => {
       expect(restoredAppState.zoom).toMatchObject(getDefaultAppState().zoom);
     });
   });
+
+  it("should handle appState.openSidebar legacy values", () => {
+    expect(restore.restoreAppState({}, null).openSidebar).toBe(null);
+    expect(
+      restore.restoreAppState({ openSidebar: "library" } as any, null)
+        .openSidebar,
+    ).toEqual({ name: DEFAULT_SIDEBAR.name });
+    expect(
+      restore.restoreAppState({ openSidebar: "xxx" } as any, null).openSidebar,
+    ).toEqual({ name: DEFAULT_SIDEBAR.name });
+    // while "library" was our legacy sidebar name, we can't assume it's legacy
+    // value as it may be some host app's custom sidebar name ¯\_(ツ)_/¯
+    expect(
+      restore.restoreAppState({ openSidebar: { name: "library" } } as any, null)
+        .openSidebar,
+    ).toEqual({ name: "library" });
+    expect(
+      restore.restoreAppState(
+        { openSidebar: { name: DEFAULT_SIDEBAR.name, tab: "ola" } } as any,
+        null,
+      ).openSidebar,
+    ).toEqual({ name: DEFAULT_SIDEBAR.name, tab: "ola" });
+  });
 });
 
 describe("restore", () => {

+ 7 - 2
src/tests/library.test.tsx

@@ -189,10 +189,15 @@ describe("library menu", () => {
     const latestLibrary = await h.app.library.getLatestLibrary();
     expect(latestLibrary.length).toBe(0);
 
-    const libraryButton = container.querySelector(".library-button");
+    const libraryButton = container.querySelector(".sidebar-trigger");
 
     fireEvent.click(libraryButton!);
-    fireEvent.click(container.querySelector(".Sidebar__dropdown-btn")!);
+    fireEvent.click(
+      queryByTestId(
+        container.querySelector(".layer-ui__library")!,
+        "dropdown-menu-button",
+      )!,
+    );
     queryByTestId(container, "lib-dropdown--load")!.click();
 
     const libraryItems = parseLibraryJSON(await libraryJSONPromise);

+ 1 - 1
src/tests/packages/__snapshots__/utils.test.ts.snap

@@ -25,6 +25,7 @@ Object {
   "currentItemStrokeWidth": 1,
   "currentItemTextAlign": "left",
   "cursorButton": "up",
+  "defaultSidebarDockedPreference": false,
   "draggingElement": null,
   "editingElement": null,
   "editingGroupId": null,
@@ -41,7 +42,6 @@ Object {
   "isLoading": false,
   "isResizing": false,
   "isRotating": false,
-  "isSidebarDocked": false,
   "lastPointerDownWith": "mouse",
   "multiElement": null,
   "name": "name",

+ 15 - 8
src/types.ts

@@ -94,6 +94,9 @@ export type LastActiveTool =
     }
   | null;
 
+export type SidebarName = string;
+export type SidebarTabName = string;
+
 export type AppState = {
   contextMenu: {
     items: ContextMenuItems;
@@ -159,16 +162,22 @@ export type AppState = {
   isResizing: boolean;
   isRotating: boolean;
   zoom: Zoom;
-  // mobile-only
   openMenu: "canvas" | "shape" | null;
   openPopup:
     | "canvasColorPicker"
     | "backgroundColorPicker"
     | "strokeColorPicker"
     | null;
-  openSidebar: "library" | "customSidebar" | null;
+  openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
   openDialog: "imageExport" | "help" | "jsonExport" | null;
-  isSidebarDocked: boolean;
+  /**
+   * Reflects user preference for whether the default sidebar should be docked.
+   *
+   * NOTE this is only a user preference and does not reflect the actual docked
+   * state of the sidebar, because the host apps can override this through
+   * a DefaultSidebar prop, which is not reflected back to the appState.
+   */
+  defaultSidebarDockedPreference: boolean;
 
   lastPointerDownWith: PointerType;
   selectedElementIds: { [id: string]: boolean };
@@ -335,10 +344,6 @@ export interface ExcalidrawProps {
     pointerDownState: PointerDownState,
   ) => void;
   onScrollChange?: (scrollX: number, scrollY: number) => void;
-  /**
-   * Render function that renders custom <Sidebar /> component.
-   */
-  renderSidebar?: () => JSX.Element | null;
   children?: React.ReactNode;
 }
 
@@ -426,6 +431,8 @@ export type AppClassProperties = {
   device: App["device"];
   scene: App["scene"];
   pasteFromClipboard: App["pasteFromClipboard"];
+  id: App["id"];
+  onInsertElements: App["onInsertElements"];
 };
 
 export type PointerDownState = Readonly<{
@@ -517,7 +524,7 @@ export type ExcalidrawImperativeAPI = {
   setActiveTool: InstanceType<typeof App>["setActiveTool"];
   setCursor: InstanceType<typeof App>["setCursor"];
   resetCursor: InstanceType<typeof App>["resetCursor"];
-  toggleMenu: InstanceType<typeof App>["toggleMenu"];
+  toggleSidebar: InstanceType<typeof App>["toggleSidebar"];
 };
 
 export type Device = Readonly<{

+ 17 - 3
src/utils.ts

@@ -767,16 +767,30 @@ export const queryFocusableElements = (container: HTMLElement | null) => {
     : [];
 };
 
-export const isShallowEqual = <T extends Record<string, any>>(
+export const isShallowEqual = <
+  T extends Record<string, any>,
+  I extends keyof T,
+>(
   objA: T,
   objB: T,
+  comparators?: Record<I, (a: T[I], b: T[I]) => boolean>,
+  debug = false,
 ) => {
   const aKeys = Object.keys(objA);
-  const bKeys = Object.keys(objA);
+  const bKeys = Object.keys(objB);
   if (aKeys.length !== bKeys.length) {
     return false;
   }
-  return aKeys.every((key) => objA[key] === objB[key]);
+  return aKeys.every((key) => {
+    const comparator = comparators?.[key as I];
+    const ret = comparator
+      ? comparator(objA[key], objB[key])
+      : objA[key] === objB[key];
+    if (!ret && debug) {
+      console.warn(`isShallowEqual: ${key} not equal ->`, objA[key], objB[key]);
+    }
+    return ret;
+  });
 };
 
 // taken from Radix UI

+ 142 - 17
yarn.lock

@@ -1262,6 +1262,13 @@
   dependencies:
     regenerator-runtime "^0.13.10"
 
+"@babel/runtime@^7.13.10":
+  version "7.20.13"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.13.tgz#7055ab8a7cff2b8f6058bf6ae45ff84ad2aded4b"
+  integrity sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA==
+  dependencies:
+    regenerator-runtime "^0.13.11"
+
 "@babel/template@^7.18.10", "@babel/template@^7.3.3":
   version "7.18.10"
   resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71"
@@ -1437,13 +1444,6 @@
   resolved "https://registry.yarnpkg.com/@csstools/selector-specificity/-/selector-specificity-2.0.2.tgz#1bfafe4b7ed0f3e4105837e056e0a89b108ebe36"
   integrity sha512-IkpVW/ehM1hWKln4fCA3NzJU8KwD+kIOvPZA4cqxoJHtE21CCzjyp+Kxbu0i5I4tBNOlXPL9mjwnWlL0VEG4Fg==
 
-"@dwelle/[email protected]":
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/@dwelle/tunnel-rat/-/tunnel-rat-0.1.1.tgz#0a0b235f8fc22ff1cf47ed102f4cc612eb51bc71"
-  integrity sha512-jb5/ZsT/af1J7tnbBXp7KO1xEyw61lWSDqJ+Bqdc6JlL3vbAvsifNhe+/mRFs6aSBCRaDqp5f2pJDHtA3MUZLw==
-  dependencies:
-    zustand "^4.3.2"
-
 "@eslint/eslintrc@^0.4.3":
   version "0.4.3"
   resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.3.tgz#9e42981ef035beb3dd49add17acb96e8ff6f394c"
@@ -2164,6 +2164,131 @@
   resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
   integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==
 
+"@radix-ui/[email protected]":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.0.0.tgz#e1d8ef30b10ea10e69c76e896f608d9276352253"
+  integrity sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+
+"@radix-ui/[email protected]":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.0.1.tgz#259506f97c6703b36291826768d3c1337edd1de5"
+  integrity sha512-uuiFbs+YCKjn3X1DTSx9G7BHApu4GHbi3kgiwsnFUbOKCrwejAJv4eE4Vc8C0Oaxt9T0aV4ox0WCOdx+39Xo+g==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-compose-refs" "1.0.0"
+    "@radix-ui/react-context" "1.0.0"
+    "@radix-ui/react-primitive" "1.0.1"
+    "@radix-ui/react-slot" "1.0.1"
+
+"@radix-ui/[email protected]":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz#37595b1f16ec7f228d698590e78eeed18ff218ae"
+  integrity sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+
+"@radix-ui/[email protected]":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.0.tgz#f38e30c5859a9fb5e9aa9a9da452ee3ed9e0aee0"
+  integrity sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+
+"@radix-ui/[email protected]":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.0.0.tgz#a2e0b552352459ecf96342c79949dd833c1e6e45"
+  integrity sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+
+"@radix-ui/[email protected]":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.0.0.tgz#8d43224910741870a45a8c9d092f25887bb6d11e"
+  integrity sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-use-layout-effect" "1.0.0"
+
+"@radix-ui/[email protected]":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.0.0.tgz#814fe46df11f9a468808a6010e3f3ca7e0b2e84a"
+  integrity sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-compose-refs" "1.0.0"
+    "@radix-ui/react-use-layout-effect" "1.0.0"
+
+"@radix-ui/[email protected]":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-1.0.1.tgz#c1ebcce283dd2f02e4fbefdaa49d1cb13dbc990a"
+  integrity sha512-fHbmislWVkZaIdeF6GZxF0A/NH/3BjrGIYj+Ae6eTmTCr7EB0RQAAVEiqsXK6p3/JcRqVSBQoceZroj30Jj3XA==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-slot" "1.0.1"
+
+"@radix-ui/[email protected]":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.2.tgz#d8ac2e3b8006697bdfc2b0eb06bef7e15b6245de"
+  integrity sha512-HLK+CqD/8pN6GfJm3U+cqpqhSKYAWiOJDe+A+8MfxBnOue39QEeMa43csUn2CXCHQT0/mewh1LrrG4tfkM9DMA==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/primitive" "1.0.0"
+    "@radix-ui/react-collection" "1.0.1"
+    "@radix-ui/react-compose-refs" "1.0.0"
+    "@radix-ui/react-context" "1.0.0"
+    "@radix-ui/react-direction" "1.0.0"
+    "@radix-ui/react-id" "1.0.0"
+    "@radix-ui/react-primitive" "1.0.1"
+    "@radix-ui/react-use-callback-ref" "1.0.0"
+    "@radix-ui/react-use-controllable-state" "1.0.0"
+
+"@radix-ui/[email protected]":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.1.tgz#e7868c669c974d649070e9ecbec0b367ee0b4d81"
+  integrity sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-compose-refs" "1.0.0"
+
+"@radix-ui/[email protected]":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.0.2.tgz#8f5ec73ca41b151a413bdd6e00553408ff34ce07"
+  integrity sha512-gOUwh+HbjCuL0UCo8kZ+kdUEG8QtpdO4sMQduJ34ZEz0r4922g9REOBM+vIsfwtGxSug4Yb1msJMJYN2Bk8TpQ==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/primitive" "1.0.0"
+    "@radix-ui/react-context" "1.0.0"
+    "@radix-ui/react-direction" "1.0.0"
+    "@radix-ui/react-id" "1.0.0"
+    "@radix-ui/react-presence" "1.0.0"
+    "@radix-ui/react-primitive" "1.0.1"
+    "@radix-ui/react-roving-focus" "1.0.2"
+    "@radix-ui/react-use-controllable-state" "1.0.0"
+
+"@radix-ui/[email protected]":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz#9e7b8b6b4946fe3cbe8f748c82a2cce54e7b6a90"
+  integrity sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+
+"@radix-ui/[email protected]":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz#a64deaafbbc52d5d407afaa22d493d687c538b7f"
+  integrity sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-use-callback-ref" "1.0.0"
+
+"@radix-ui/[email protected]":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz#2fc19e97223a81de64cd3ba1dc42ceffd82374dc"
+  integrity sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+
 "@rollup/plugin-babel@^5.2.0":
   version "5.3.1"
   resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283"
@@ -9160,7 +9285,7 @@ regenerator-runtime@^0.13.10:
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz#ed07b19616bcbec5da6274ebc75ae95634bfc2ee"
   integrity sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw==
 
-regenerator-runtime@^0.13.9:
+regenerator-runtime@^0.13.11, regenerator-runtime@^0.13.9:
   version "0.13.11"
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"
   integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==
@@ -10245,12 +10370,12 @@ tsutils@^3.21.0:
   dependencies:
     tslib "^1.8.1"
 
[email protected].0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/tunnel-rat/-/tunnel-rat-0.1.0.tgz#62cfbaf1b24cabac9318fe45ef26d70dc40e86fe"
-  integrity sha512-/FKZLBXCoKhA7Wz+dsqitrItaLXYmT2bkZXod+1UuR4JqHtdb54yHvHhmMgLg+eyH1Od/CCnhA2VQQ2A/54Tcw==
[email protected].2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/tunnel-rat/-/tunnel-rat-0.1.2.tgz#1717efbc474ea2d8aa05a91622457a6e201c0aeb"
+  integrity sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==
   dependencies:
-    zustand "^4.1.0"
+    zustand "^4.3.2"
 
 type-check@^0.4.0, type-check@~0.4.0:
   version "0.4.0"
@@ -11035,9 +11160,9 @@ yocto-queue@^0.1.0:
   resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
   integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
 
-zustand@^4.1.0, zustand@^4.3.2:
-  version "4.3.2"
-  resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.3.2.tgz#bb121fcad84c5a569e94bd1a2695e1a93ba85d39"
-  integrity sha512-rd4haDmlwMTVWVqwvgy00ny8rtti/klRoZjFbL/MAcDnmD5qSw/RZc+Vddstdv90M5Lv6RPgWvm1Hivyn0QgJw==
+zustand@^4.3.2:
+  version "4.3.7"
+  resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.3.7.tgz#501b1f0393a7f1d103332e45ab574be5747fedce"
+  integrity sha512-dY8ERwB9Nd21ellgkBZFhudER8KVlelZm8388B5nDAXhO/+FZDhYMuRnqDgu5SYyRgz/iaf8RKnbUs/cHfOGlQ==
   dependencies:
     use-sync-external-store "1.2.0"