snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s

This commit is contained in:
upstream-mirror
2026-09-16 05:31:04 +05:00
commit 1a8d07ca6c
1286 changed files with 360144 additions and 0 deletions

View File

@@ -0,0 +1,175 @@
@use "../../css/variables.module" as *;
.excalidraw {
.sidebar {
display: flex;
flex-direction: column;
position: absolute;
top: 0;
bottom: 0;
right: 0;
z-index: var(--zIndex-ui-library);
margin: 0;
padding: 0;
box-sizing: border-box;
background-color: var(--sidebar-bg-color);
box-shadow: var(--sidebar-shadow);
pointer-events: var(--ui-pointerEvents);
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"] & {
left: 0;
right: auto;
}
&--docked {
box-shadow: none;
}
: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;
}
}
}
}

View File

@@ -0,0 +1,396 @@
import React from "react";
import { vi } from "vitest";
import { DEFAULT_SIDEBAR } from "@excalidraw/common";
import { Excalidraw, Sidebar } from "../../index";
import {
act,
fireEvent,
queryAllByTestId,
queryByTestId,
render,
waitFor,
withExcalidrawDimensions,
} from "../../tests/test-utils";
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();
},
);
});
});
});

View File

@@ -0,0 +1,233 @@
import clsx from "clsx";
import React, {
useEffect,
useLayoutEffect,
useRef,
useState,
forwardRef,
useImperativeHandle,
useCallback,
} from "react";
import {
CLASSES,
EVENT,
isDevEnv,
KEYS,
updateObject,
} from "@excalidraw/common";
import { useUIAppState } from "../../context/ui-appState";
import { atom, useSetAtom } from "../../editor-jotai";
import { useOutsideClick } from "../../hooks/useOutsideClick";
import { useEditorInterface, useExcalidrawSetAppState } from "../App";
import { Island } from "../Island";
import { SidebarHeader } from "./SidebarHeader";
import { SidebarTabTrigger } from "./SidebarTabTrigger";
import { SidebarTabTriggers } from "./SidebarTabTriggers";
import { SidebarTrigger } from "./SidebarTrigger";
import { SidebarPropsContext } from "./common";
import { SidebarTabs } from "./SidebarTabs";
import { SidebarTab } from "./SidebarTab";
import "./Sidebar.scss";
import type { SidebarProps, SidebarPropsContextValue } from "./common";
/**
* 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 (isDevEnv() && 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 editorInterface = useEditorInterface();
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 || !editorInterface.canFitSidebar) {
closeLibrary();
}
},
[closeLibrary, docked, editorInterface.canFitSidebar],
),
);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === KEYS.ESCAPE &&
(!docked || !editorInterface.canFitSidebar)
) {
closeLibrary();
}
};
document.addEventListener(EVENT.KEYDOWN, handleKeyDown);
return () => {
document.removeEventListener(EVENT.KEYDOWN, handleKeyDown);
};
}, [closeLibrary, docked, editorInterface.canFitSidebar]);
return (
<Island
{...rest}
className={clsx(
CLASSES.SIDEBAR,
{ "sidebar--docked": docked },
className,
)}
// on mobile the sidebar shouldn't push the viewport around even
// when opened (it's treated as a temporary overlay)
data-viewport-ui={
editorInterface.formFactor !== "phone" ? "side" : undefined
}
data-viewport-ui-name={
editorInterface.formFactor !== "phone" ? "sidebar" : undefined
}
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";

View File

@@ -0,0 +1,59 @@
import clsx from "clsx";
import { useContext } from "react";
import { t } from "../../i18n";
import { useEditorInterface } from "../App";
import { Button } from "../Button";
import { Tooltip } from "../Tooltip";
import { CloseIcon, PinIcon } from "../icons";
import { SidebarPropsContext } from "./common";
export const SidebarHeader = ({
children,
className,
}: {
children?: React.ReactNode;
className?: string;
}) => {
const editorInterface = useEditorInterface();
const props = useContext(SidebarPropsContext);
const renderDockButton = !!(
editorInterface.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";

View File

@@ -0,0 +1,19 @@
import { Tabs as RadixTabs } from "radix-ui";
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";

View File

@@ -0,0 +1,27 @@
import { Tabs as RadixTabs } from "radix-ui";
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";

View File

@@ -0,0 +1,16 @@
import { Tabs as RadixTabs } from "radix-ui";
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";

View File

@@ -0,0 +1,37 @@
import { Tabs as RadixTabs } from "radix-ui";
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";

View File

@@ -0,0 +1,48 @@
@use "../../css/variables.module" as *;
.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);
}
// the <button> wrapper — resets the native button chrome (the visual
// styling lives on the inner .sidebar-trigger)
&__label-element {
align-self: flex-start;
appearance: none;
margin: 0;
padding: 0;
border: none;
background-color: transparent;
font: inherit;
color: inherit;
text-align: inherit;
}
}
.default-sidebar-trigger .sidebar-trigger__label {
display: block;
white-space: nowrap;
}
&.excalidraw--mobile .default-sidebar-trigger .sidebar-trigger__label {
display: none;
}
}

View File

@@ -0,0 +1,53 @@
import clsx from "clsx";
import { useUIAppState } from "../../context/ui-appState";
import { useExcalidrawSetAppState } from "../App";
import "./SidebarTrigger.scss";
import type { SidebarTriggerProps } from "./common";
export const SidebarTrigger = ({
name,
tab,
icon,
title,
children,
onToggle,
className,
style,
}: SidebarTriggerProps) => {
const setAppState = useExcalidrawSetAppState();
const appState = useUIAppState();
const isOpen = appState.openSidebar?.name === name;
return (
<button
type="button"
title={title}
className="sidebar-trigger__label-element"
aria-label={title}
aria-keyshortcuts="0"
aria-pressed={isOpen}
onClick={() => {
document
.querySelector(".layer-ui__wrapper")
?.classList.remove("animate");
const nextOpen = !isOpen;
setAppState({
openSidebar: nextOpen ? { name, tab } : null,
openMenu: null,
openPopup: null,
});
onToggle?.(nextOpen);
}}
>
<div className={clsx("sidebar-trigger", className)} style={style}>
{icon && <div>{icon}</div>}
{children && <div className="sidebar-trigger__label">{children}</div>}
</div>
</button>
);
};
SidebarTrigger.displayName = "SidebarTrigger";

View File

@@ -0,0 +1,43 @@
import React from "react";
import type { AppState, SidebarName, SidebarTabName } from "../../types";
import type { JSX } from "react";
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);

View File

@@ -0,0 +1,43 @@
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);
};