diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | 6ec259a0e71174651bae95d4628138bf6fd68742 (patch) | |
| tree | 5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/components/Sidebar | |
| parent | 16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff) | |
refactor: packages/
Diffstat (limited to 'packages/excalidraw/components/Sidebar')
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); +}; |
