aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/components/Sidebar
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/components/Sidebar
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/components/Sidebar')
-rw-r--r--packages/excalidraw/components/Sidebar/Sidebar.scss176
-rw-r--r--packages/excalidraw/components/Sidebar/Sidebar.test.tsx393
-rw-r--r--packages/excalidraw/components/Sidebar/Sidebar.tsx213
-rw-r--r--packages/excalidraw/components/Sidebar/SidebarHeader.tsx57
-rw-r--r--packages/excalidraw/components/Sidebar/SidebarTab.tsx18
-rw-r--r--packages/excalidraw/components/Sidebar/SidebarTabTrigger.tsx26
-rw-r--r--packages/excalidraw/components/Sidebar/SidebarTabTriggers.tsx16
-rw-r--r--packages/excalidraw/components/Sidebar/SidebarTabs.tsx36
-rw-r--r--packages/excalidraw/components/Sidebar/SidebarTrigger.scss38
-rw-r--r--packages/excalidraw/components/Sidebar/SidebarTrigger.tsx45
-rw-r--r--packages/excalidraw/components/Sidebar/common.ts42
-rw-r--r--packages/excalidraw/components/Sidebar/siderbar.test.helpers.tsx42
12 files changed, 1102 insertions, 0 deletions
diff --git a/packages/excalidraw/components/Sidebar/Sidebar.scss b/packages/excalidraw/components/Sidebar/Sidebar.scss
new file mode 100644
index 0000000..c7776d1
--- /dev/null
+++ b/packages/excalidraw/components/Sidebar/Sidebar.scss
@@ -0,0 +1,176 @@
+@import "open-color/open-color";
+@import "../../css/variables.module.scss";
+
+.excalidraw {
+ .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);
+
+ pointer-events: var(--ui-pointerEvents);
+
+ :root[dir="rtl"] & {
+ left: 0;
+ right: auto;
+ }
+
+ &--docked {
+ box-shadow: none;
+ }
+
+ overflow: hidden;
+ border-radius: 0;
+ width: calc(var(--right-sidebar-width) - var(--space-factor) * 2);
+
+ border-left: 1px solid var(--sidebar-border-color);
+
+ :root[dir="rtl"] & {
+ border-right: 1px solid var(--sidebar-border-color);
+ border-left: 0;
+ }
+ }
+
+ // ---------------------------- sidebar header ------------------------------
+
+ .sidebar__header {
+ box-sizing: border-box;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ padding: 1rem 0.75rem;
+ position: relative;
+
+ &::after {
+ content: "";
+ width: calc(100% - 1.5rem);
+ height: 1px;
+ background: var(--sidebar-border-color);
+ position: absolute;
+ bottom: -1px;
+ }
+ }
+
+ .sidebar__header__buttons {
+ gap: 0;
+ display: flex;
+ align-items: center;
+ margin-left: auto;
+
+ button {
+ @include outlineButtonStyles;
+ --button-bg: transparent;
+ border: 0 !important;
+
+ width: var(--lg-button-size);
+ height: var(--lg-button-size);
+ padding: 0;
+
+ svg {
+ width: var(--lg-icon-size);
+ height: var(--lg-icon-size);
+ }
+
+ &:hover {
+ background: var(--button-hover-bg, var(--island-bg-color));
+ }
+ }
+
+ .sidebar__dock.selected {
+ svg {
+ stroke: var(--color-primary);
+ fill: var(--color-primary);
+ }
+ }
+ }
+
+ // ---------------------------- sidebar tabs ------------------------------
+
+ .sidebar-tabs-root {
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ padding: 1rem 0;
+
+ [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;
+ }
+
+ .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;
+ 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;
+ }
+ }
+ }
+}
diff --git a/packages/excalidraw/components/Sidebar/Sidebar.test.tsx b/packages/excalidraw/components/Sidebar/Sidebar.test.tsx
new file mode 100644
index 0000000..b61529d
--- /dev/null
+++ b/packages/excalidraw/components/Sidebar/Sidebar.test.tsx
@@ -0,0 +1,393 @@
+import React from "react";
+import { DEFAULT_SIDEBAR } from "../../constants";
+import { Excalidraw, Sidebar } from "../../index";
+import {
+ act,
+ fireEvent,
+ queryAllByTestId,
+ queryByTestId,
+ render,
+ waitFor,
+ withExcalidrawDimensions,
+} from "../../tests/test-utils";
+import { vi } from "vitest";
+import {
+ assertExcalidrawWithSidebar,
+ assertSidebarDockButton,
+} from "./siderbar.test.helpers";
+
+const toggleSidebar = (
+ ...args: Parameters<typeof window.h.app.toggleSidebar>
+): Promise<boolean> => {
+ return act(() => {
+ return window.h.app.toggleSidebar(...args);
+ });
+};
+
+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>,
+ );
+
+ const node = container.querySelector("#test-sidebar-content");
+ expect(node).not.toBe(null);
+ });
+
+ it("should render only one sidebar and prefer the custom one", 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(".sidebar");
+ expect(sidebars.length).toBe(1);
+ });
+ });
+
+ it("should toggle sidebar using excalidrawAPI.toggleSidebar()", async () => {
+ const { container } = await render(
+ <Excalidraw>
+ <Sidebar name="customSidebar">
+ <div id="test-sidebar-content">42</div>
+ </Sidebar>
+ </Excalidraw>,
+ );
+
+ // sidebar isn't rendered initially
+ // -------------------------------------------------------------------------
+ await waitFor(() => {
+ const node = container.querySelector("#test-sidebar-content");
+ expect(node).toBe(null);
+ });
+
+ // toggle sidebar on
+ // -------------------------------------------------------------------------
+ expect(await toggleSidebar({ name: "customSidebar" })).toBe(true);
+
+ await waitFor(() => {
+ const node = container.querySelector("#test-sidebar-content");
+ expect(node).not.toBe(null);
+ });
+
+ // toggle sidebar off
+ // -------------------------------------------------------------------------
+ expect(await toggleSidebar({ name: "customSidebar" })).toBe(false);
+
+ await waitFor(() => {
+ const node = container.querySelector("#test-sidebar-content");
+ expect(node).toBe(null);
+ });
+
+ // force-toggle sidebar off (=> still hidden)
+ // -------------------------------------------------------------------------
+ expect(await toggleSidebar({ name: "customSidebar", force: false })).toBe(
+ false,
+ );
+
+ await waitFor(() => {
+ const node = container.querySelector("#test-sidebar-content");
+ expect(node).toBe(null);
+ });
+
+ // force-toggle sidebar on
+ // -------------------------------------------------------------------------
+ expect(await toggleSidebar({ name: "customSidebar", force: true })).toBe(
+ true,
+ );
+ expect(await toggleSidebar({ name: "customSidebar", force: true })).toBe(
+ true,
+ );
+
+ await waitFor(() => {
+ const node = container.querySelector("#test-sidebar-content");
+ expect(node).not.toBe(null);
+ });
+
+ // toggle library (= hide custom sidebar)
+ // -------------------------------------------------------------------------
+ expect(await toggleSidebar({ name: DEFAULT_SIDEBAR.name })).toBe(true);
+
+ await waitFor(() => {
+ const node = container.querySelector("#test-sidebar-content");
+ expect(node).toBe(null);
+
+ // make sure only one sidebar is rendered
+ const sidebars = container.querySelectorAll(".sidebar");
+ expect(sidebars.length).toBe(1);
+ });
+
+ // closing sidebar using `{ name: null }`
+ // -------------------------------------------------------------------------
+ expect(await toggleSidebar({ name: "customSidebar" })).toBe(true);
+ await waitFor(() => {
+ const node = container.querySelector("#test-sidebar-content");
+ expect(node).not.toBe(null);
+ });
+
+ expect(await toggleSidebar({ name: null })).toBe(false);
+ await waitFor(() => {
+ const node = container.querySelector("#test-sidebar-content");
+ expect(node).toBe(null);
+ });
+ });
+ });
+
+ 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>,
+ );
+
+ 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 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>
+ );
+ };
+
+ 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);
+ });
+
+ it("<Sidebar.Header> should render close button", async () => {
+ const onStateChange = vi.fn();
+ const CustomExcalidraw = () => {
+ return (
+ <Excalidraw
+ initialData={{
+ appState: { openSidebar: { name: "customSidebar" } },
+ }}
+ >
+ <Sidebar
+ name="customSidebar"
+ className="test-sidebar"
+ onStateChange={onStateChange}
+ >
+ <Sidebar.Header />
+ </Sidebar>
+ </Excalidraw>
+ );
+ };
+
+ const { container } = await render(<CustomExcalidraw />);
+
+ // initial open
+ expect(onStateChange).toHaveBeenCalledWith({ name: "customSidebar" });
+
+ 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);
+ });
+ });
+ });
+
+ 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);
+ },
+ );
+ });
+
+ 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);
+ },
+ );
+ });
+
+ 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);
+ },
+ );
+ });
+
+ 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>,
+ );
+
+ await withExcalidrawDimensions(
+ { width: 1920, height: 1080 },
+ async () => {
+ await assertSidebarDockButton(true);
+ },
+ );
+ });
+
+ it("shouldn't be user-dockable when only `onDock` supplied w/o `docked`", async () => {
+ // we expect warnings in this test and don't want to pollute stdout
+ const mock = jest.spyOn(console, "warn").mockImplementation(() => {});
+
+ 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);
+ },
+ );
+
+ mock.mockRestore();
+ });
+ });
+
+ describe("Sidebar.tab", () => {
+ it("should toggle sidebars tabs correctly", async () => {
+ const { container } = await render(
+ <Excalidraw>
+ <Sidebar name="custom" docked>
+ <Sidebar.Tabs>
+ <Sidebar.Tab tab="library">Library</Sidebar.Tab>
+ <Sidebar.Tab tab="comments">Comments</Sidebar.Tab>
+ </Sidebar.Tabs>
+ </Sidebar>
+ </Excalidraw>,
+ );
+
+ await withExcalidrawDimensions(
+ { width: 1920, height: 1080 },
+ async () => {
+ expect(
+ container.querySelector<HTMLElement>(
+ "[role=tabpanel][data-testid=library]",
+ ),
+ ).toBeNull();
+
+ // open library sidebar
+ expect(await toggleSidebar({ name: "custom", tab: "library" })).toBe(
+ true,
+ );
+ expect(
+ container.querySelector<HTMLElement>(
+ "[role=tabpanel][data-testid=library]",
+ ),
+ ).not.toBeNull();
+
+ // switch to comments tab
+ expect(await toggleSidebar({ name: "custom", tab: "comments" })).toBe(
+ true,
+ );
+ expect(
+ container.querySelector<HTMLElement>(
+ "[role=tabpanel][data-testid=comments]",
+ ),
+ ).not.toBeNull();
+
+ // toggle sidebar closed
+ expect(await toggleSidebar({ name: "custom", tab: "comments" })).toBe(
+ false,
+ );
+ expect(
+ container.querySelector<HTMLElement>(
+ "[role=tabpanel][data-testid=comments]",
+ ),
+ ).toBeNull();
+
+ // toggle sidebar open
+ expect(await toggleSidebar({ name: "custom", tab: "comments" })).toBe(
+ true,
+ );
+ expect(
+ container.querySelector<HTMLElement>(
+ "[role=tabpanel][data-testid=comments]",
+ ),
+ ).not.toBeNull();
+ },
+ );
+ });
+ });
+});
diff --git a/packages/excalidraw/components/Sidebar/Sidebar.tsx b/packages/excalidraw/components/Sidebar/Sidebar.tsx
new file mode 100644
index 0000000..7c747d2
--- /dev/null
+++ b/packages/excalidraw/components/Sidebar/Sidebar.tsx
@@ -0,0 +1,213 @@
+import React, {
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+ forwardRef,
+ useImperativeHandle,
+ useCallback,
+} from "react";
+import { Island } from "../Island";
+import { atom, useSetAtom } from "../../editor-jotai";
+import type { SidebarProps, SidebarPropsContextValue } from "./common";
+import { SidebarPropsContext } from "./common";
+import { SidebarHeader } from "./SidebarHeader";
+import clsx from "clsx";
+import { useDevice, 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";
+import { useUIAppState } from "../../context/ui-appState";
+import { useOutsideClick } from "../../hooks/useOutsideClick";
+
+import "./Sidebar.scss";
+
+/**
+ * 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 (import.meta.env.DEV && 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`",
+ );
+ }
+
+ const setAppState = useExcalidrawSetAppState();
+
+ const setIsSidebarDockedAtom = useSetAtom(isSidebarDockedAtom);
+
+ useLayoutEffect(() => {
+ setIsSidebarDockedAtom(!!docked);
+ return () => {
+ setIsSidebarDockedAtom(false);
+ };
+ }, [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]);
+
+ useOutsideClick(
+ 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.editor.canFitSidebar) {
+ closeLibrary();
+ }
+ },
+ [closeLibrary, docked, device.editor.canFitSidebar],
+ ),
+ );
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (
+ event.key === KEYS.ESCAPE &&
+ (!docked || !device.editor.canFitSidebar)
+ ) {
+ closeLibrary();
+ }
+ };
+ document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
+ return () => {
+ document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
+ };
+ }, [closeLibrary, docked, device.editor.canFitSidebar]);
+
+ 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 = useUIAppState();
+
+ 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]);
+
+ 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: SidebarHeader,
+ TabTriggers: SidebarTabTriggers,
+ TabTrigger: SidebarTabTrigger,
+ Tabs: SidebarTabs,
+ Tab: SidebarTab,
+ Trigger: SidebarTrigger,
+ },
+);
+Sidebar.displayName = "Sidebar";
diff --git a/packages/excalidraw/components/Sidebar/SidebarHeader.tsx b/packages/excalidraw/components/Sidebar/SidebarHeader.tsx
new file mode 100644
index 0000000..6d046ab
--- /dev/null
+++ b/packages/excalidraw/components/Sidebar/SidebarHeader.tsx
@@ -0,0 +1,57 @@
+import clsx from "clsx";
+import { useContext } from "react";
+import { t } from "../../i18n";
+import { useDevice } from "../App";
+import { SidebarPropsContext } from "./common";
+import { CloseIcon, PinIcon } from "../icons";
+import { Tooltip } from "../Tooltip";
+import { Button } from "../Button";
+
+export const SidebarHeader = ({
+ children,
+ className,
+}: {
+ children?: React.ReactNode;
+ className?: string;
+}) => {
+ const device = useDevice();
+ const props = useContext(SidebarPropsContext);
+
+ const renderDockButton = !!(
+ device.editor.canFitSidebar && props.shouldRenderDockButton
+ );
+
+ return (
+ <div
+ className={clsx("sidebar__header", className)}
+ data-testid="sidebar-header"
+ >
+ {children}
+ <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")}
+ >
+ {PinIcon}
+ </Button>
+ </Tooltip>
+ )}
+ <Button
+ data-testid="sidebar-close"
+ className="sidebar__close"
+ onSelect={props.onCloseRequest}
+ aria-label={t("buttons.close")}
+ >
+ {CloseIcon}
+ </Button>
+ </div>
+ </div>
+ );
+};
+
+SidebarHeader.displayName = "SidebarHeader";
diff --git a/packages/excalidraw/components/Sidebar/SidebarTab.tsx b/packages/excalidraw/components/Sidebar/SidebarTab.tsx
new file mode 100644
index 0000000..6fddab0
--- /dev/null
+++ b/packages/excalidraw/components/Sidebar/SidebarTab.tsx
@@ -0,0 +1,18 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import type { SidebarTabName } from "../../types";
+
+export const SidebarTab = ({
+ tab,
+ children,
+ ...rest
+}: {
+ tab: SidebarTabName;
+ children: React.ReactNode;
+} & React.HTMLAttributes<HTMLDivElement>) => {
+ return (
+ <RadixTabs.Content {...rest} value={tab} data-testid={tab}>
+ {children}
+ </RadixTabs.Content>
+ );
+};
+SidebarTab.displayName = "SidebarTab";
diff --git a/packages/excalidraw/components/Sidebar/SidebarTabTrigger.tsx b/packages/excalidraw/components/Sidebar/SidebarTabTrigger.tsx
new file mode 100644
index 0000000..8509ef2
--- /dev/null
+++ b/packages/excalidraw/components/Sidebar/SidebarTabTrigger.tsx
@@ -0,0 +1,26 @@
+import * as RadixTabs from "@radix-ui/react-tabs";
+import type { 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";
diff --git a/packages/excalidraw/components/Sidebar/SidebarTabTriggers.tsx b/packages/excalidraw/components/Sidebar/SidebarTabTriggers.tsx
new file mode 100644
index 0000000..0be187b
--- /dev/null
+++ b/packages/excalidraw/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";
diff --git a/packages/excalidraw/components/Sidebar/SidebarTabs.tsx b/packages/excalidraw/components/Sidebar/SidebarTabs.tsx
new file mode 100644
index 0000000..a681b5e
--- /dev/null
+++ b/packages/excalidraw/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";
diff --git a/packages/excalidraw/components/Sidebar/SidebarTrigger.scss b/packages/excalidraw/components/Sidebar/SidebarTrigger.scss
new file mode 100644
index 0000000..5b003cd
--- /dev/null
+++ b/packages/excalidraw/components/Sidebar/SidebarTrigger.scss
@@ -0,0 +1,38 @@
+@import "../../css/variables.module.scss";
+
+.excalidraw {
+ .sidebar-trigger {
+ @include outlineButtonStyles;
+ @include filledButtonOnCanvas;
+
+ 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-element {
+ align-self: flex-start;
+ }
+ }
+
+ .default-sidebar-trigger .sidebar-trigger__label {
+ display: block;
+ white-space: nowrap;
+ }
+
+ &.excalidraw--mobile .default-sidebar-trigger .sidebar-trigger__label {
+ display: none;
+ }
+}
diff --git a/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx b/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx
new file mode 100644
index 0000000..a26e52d
--- /dev/null
+++ b/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx
@@ -0,0 +1,45 @@
+import { useExcalidrawSetAppState } from "../App";
+import type { SidebarTriggerProps } from "./common";
+import { useUIAppState } from "../../context/ui-appState";
+import clsx from "clsx";
+
+import "./SidebarTrigger.scss";
+
+export const SidebarTrigger = ({
+ name,
+ tab,
+ icon,
+ title,
+ children,
+ onToggle,
+ className,
+ style,
+}: SidebarTriggerProps) => {
+ const setAppState = useExcalidrawSetAppState();
+ const appState = useUIAppState();
+
+ return (
+ <label title={title} className="sidebar-trigger__label-element">
+ <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";
diff --git a/packages/excalidraw/components/Sidebar/common.ts b/packages/excalidraw/components/Sidebar/common.ts
new file mode 100644
index 0000000..35c0c8b
--- /dev/null
+++ b/packages/excalidraw/components/Sidebar/common.ts
@@ -0,0 +1,42 @@
+import type { JSX } from "react";
+import React from "react";
+import type { 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 open/close or tab change.
+ */
+ onStateChange?: (state: AppState["openSidebar"]) => void;
+ /**
+ * supply alongside `docked` prop in order to make the Sidebar user-dockable
+ */
+ onDock?: (docked: boolean) => void;
+ docked?: 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,
+ "onDock" | "docked"
+> & { onCloseRequest: () => void; shouldRenderDockButton: boolean };
+
+export const SidebarPropsContext =
+ React.createContext<SidebarPropsContextValue>({} as SidebarPropsContextValue);
diff --git a/packages/excalidraw/components/Sidebar/siderbar.test.helpers.tsx b/packages/excalidraw/components/Sidebar/siderbar.test.helpers.tsx
new file mode 100644
index 0000000..c2a3743
--- /dev/null
+++ b/packages/excalidraw/components/Sidebar/siderbar.test.helpers.tsx
@@ -0,0 +1,42 @@
+import React from "react";
+import { Excalidraw } from "../..";
+import {
+ GlobalTestState,
+ queryByTestId,
+ render,
+ withExcalidrawDimensions,
+} from "../../tests/test-utils";
+
+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);
+};