From 547d944e9381aead4eb981d77144b7b4cc454364 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Thu, 26 Feb 2026 18:01:19 -0600 Subject: [PATCH] feat: context menu component --- .oxlintrc.json | 3 +- src/__tests__/ContextMenu.test.tsx | 197 ++++++++++++++++++ src/components/ContextMenu/ContextMenu.tsx | 165 +++++++++++++++ src/components/ContextMenu/index.ts | 2 + src/components/DocumentTabs/DocumentTabs.tsx | 114 +++------- src/components/Sidebar/DocumentItem.tsx | 59 +++++- src/components/Sidebar/Sidebar.tsx | 1 + .../Sidebar/SidebarLocationItem.tsx | 92 +++++--- src/components/Sidebar/TreeItem.tsx | 3 + src/components/icons.tsx | 30 +++ src/components/layout/AppHeaderBar.tsx | 4 +- 11 files changed, 548 insertions(+), 122 deletions(-) create mode 100644 src/__tests__/ContextMenu.test.tsx create mode 100644 src/components/ContextMenu/ContextMenu.tsx create mode 100644 src/components/ContextMenu/index.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 1fe6a90..1b5a21a 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -14,7 +14,8 @@ "init-declarations": "off", "no-warning-comments": "off", "unicorn/no-array-for-each": "warn", - "react/exhaustive-deps": "warn" + "react/exhaustive-deps": "warn", + "react/jsx-max-depth": ["warn", { "max": 3 }] }, "settings": { "jsx-a11y": { "polymorphicPropName": null, "components": {}, "attributes": {} }, diff --git a/src/__tests__/ContextMenu.test.tsx b/src/__tests__/ContextMenu.test.tsx new file mode 100644 index 0000000..5993003 --- /dev/null +++ b/src/__tests__/ContextMenu.test.tsx @@ -0,0 +1,197 @@ +import { ContextMenu, useContextMenu } from "$components/ContextMenu"; +import { act, fireEvent, render, renderHook, screen } from "@testing-library/react"; +import { useCallback } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const origin = { x: 0, y: 0 }; + +const createPosition = (x?: number, y?: number) => ({ x: x ?? 0, y: y ?? 0 }); + +function ContextMenuHarness( + { items, isOpen = false, position }: { + items: Parameters[0]["items"]; + isOpen?: boolean; + position?: { x: number; y: number }; + }, +) { + const close = useCallback(() => {}, []); + return ( + + ); +} + +function createItems(items: Parameters[0]["items"] = []) { + return items; +} + +const defaultItems = createItems([{ label: "Edit", onClick: vi.fn() }, { label: "Copy", onClick: vi.fn() }, { + divider: true, +}, { label: "Delete", onClick: vi.fn(), danger: true }]); + +const itemsWithIcons = createItems([{ + label: "Edit", + onClick: vi.fn(), + icon: Icon, +}]); + +const disabledItems = createItems([{ label: "Disabled", onClick: vi.fn(), disabled: true }]); + +describe("ContextMenu", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("rendering", () => { + it("does not render when closed", () => { + render(); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); + + it("renders when open", () => { + render(); + expect(screen.getByRole("menu")).toBeInTheDocument(); + }); + + it("renders all menu items", () => { + render(); + expect(screen.getByRole("menuitem", { name: "Edit" })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Copy" })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: "Delete" })).toBeInTheDocument(); + }); + + it("renders divider between items", () => { + const { container } = render(); + const divider = container.querySelector(".bg-border-subtle.h-px"); + expect(divider).toBeInTheDocument(); + }); + + it("renders icons when provided", () => { + render(); + expect(screen.getByTestId("edit-icon")).toBeInTheDocument(); + }); + }); + + describe("positioning", () => { + it("positions menu at specified coordinates", () => { + const pos = createPosition(50, 75); + render(); + const menu = screen.getByRole("menu"); + expect(menu).toHaveStyle({ left: "50px", top: "75px" }); + }); + }); + + describe("interactions", () => { + it("calls onClick and onClose when menu item is clicked", () => { + const onClick = vi.fn(); + const onClose = vi.fn(); + const clickableItems = createItems([{ label: "Action", onClick }]); + + render(); + + fireEvent.click(screen.getByRole("menuitem", { name: "Action" })); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("does not call onClick when item is disabled", () => { + const onClick = vi.fn(); + const onClose = vi.fn(); + const items = createItems([{ label: "Disabled Action", onClick, disabled: true }]); + + render(); + + fireEvent.click(screen.getByRole("menuitem", { name: "Disabled Action" })); + + expect(onClick).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("closes on click outside", () => { + const onClose = vi.fn(); + + render( +
+ + +
, + ); + + fireEvent.click(screen.getByTestId("outside")); + + expect(onClose).toHaveBeenCalled(); + }); + + it("closes on Escape key", () => { + const onClose = vi.fn(); + + render(); + + fireEvent.keyDown(document, { key: "Escape" }); + + expect(onClose).toHaveBeenCalled(); + }); + + it("closes on contextmenu event elsewhere", () => { + const onClose = vi.fn(); + + render( +
+ + +
, + ); + + fireEvent.contextMenu(screen.getByTestId("another-trigger")); + + expect(onClose).toHaveBeenCalled(); + }); + }); + + describe("styling", () => { + it("applies danger styling to danger items", () => { + render(); + const deleteItem = screen.getByRole("menuitem", { name: "Delete" }); + expect(deleteItem).toHaveClass("text-support-error"); + }); + + it("applies disabled styling to disabled items", () => { + render(); + const disabledItem = screen.getByRole("menuitem", { name: "Disabled" }); + expect(disabledItem).toHaveClass("text-text-disabled"); + }); + }); +}); + +describe("useContextMenu", () => { + it("returns closed state initially", () => { + const { result } = renderHook(() => useContextMenu()); + expect(result.current.isOpen).toBe(false); + expect(result.current.position).toEqual(origin); + }); + + it("opens on right-click with mouse position", () => { + const { result } = renderHook(() => useContextMenu()); + + act(() => { + result.current.open({ preventDefault: vi.fn(), clientX: 150, clientY: 200 } as unknown as React.MouseEvent); + }); + + expect(result.current.isOpen).toBe(true); + expect(result.current.position).toEqual({ x: 150, y: 200 }); + }); + + it("closes when close is called", () => { + const { result } = renderHook(() => useContextMenu()); + + act(() => { + result.current.open({ preventDefault: vi.fn(), clientX: 100, clientY: 100 } as unknown as React.MouseEvent); + }); + expect(result.current.isOpen).toBe(true); + + act(() => { + result.current.close(); + }); + expect(result.current.isOpen).toBe(false); + }); +}); diff --git a/src/components/ContextMenu/ContextMenu.tsx b/src/components/ContextMenu/ContextMenu.tsx new file mode 100644 index 0000000..3461bba --- /dev/null +++ b/src/components/ContextMenu/ContextMenu.tsx @@ -0,0 +1,165 @@ +import { Button } from "$components/Button"; +import { NO_MOTION_TRANSITION } from "$constants"; +import { useSkipAnimation } from "$hooks/useMotion"; +import { useViewportTier } from "$hooks/useViewportTier"; +import { cn } from "$utils/tw"; +import { AnimatePresence, motion } from "motion/react"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; + +const CONTEXT_MENU_INITIAL = { opacity: 0, scale: 0.96 }; +const CONTEXT_MENU_ANIMATE = { opacity: 1, scale: 1 }; +const CONTEXT_MENU_EXIT = { opacity: 0, scale: 0.96 }; +const CONTEXT_MENU_TRANSITION = { duration: 0.12, ease: "easeOut" as const }; + +export type ContextMenuItem = { + label: string; + onClick: () => void; + icon?: ReactNode; + disabled?: boolean; + danger?: boolean; +}; + +export type ContextMenuDivider = { divider: true }; + +type MenuItem = ContextMenuItem | ContextMenuDivider; + +function isDivider(item: MenuItem): item is ContextMenuDivider { + return "divider" in item && item.divider === true; +} + +export type ContextMenuProps = { + isOpen: boolean; + position: { x: number; y: number }; + onClose: () => void; + items: MenuItem[]; +}; + +function ContextMenuItem_({ item, onClose }: { item: ContextMenuItem; onClose: () => void }) { + const handleClick = useCallback(() => { + if (item.disabled) return; + item.onClick(); + onClose(); + }, [item, onClose]); + + const classes = cn( + "w-full px-3 py-2 bg-transparent border-none text-[0.8125rem] cursor-pointer text-left rounded flex items-center gap-2", + { + "text-text-primary hover:bg-layer-hover-02": !item.disabled, + "text-text-disabled cursor-not-allowed": item.disabled, + "text-support-error hover:bg-layer-hover-02": item.danger, + }, + ); + + return ( + + ); +} + +function ContextMenuContent( + { position, items, onClose }: { position: { x: number; y: number }; items: MenuItem[]; onClose: () => void }, +) { + const { isNarrow } = useViewportTier(); + const skipAnimation = useSkipAnimation(); + const transition = useMemo(() => skipAnimation ? NO_MOTION_TRANSITION : CONTEXT_MENU_TRANSITION, [skipAnimation]); + const menuRef = useRef(null); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + useEffect(() => { + const menu = menuRef.current; + if (!menu) return; + + const rect = menu.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + if (rect.right > viewportWidth) { + menu.style.left = `${viewportWidth - rect.width - 8}px`; + } + if (rect.bottom > viewportHeight) { + menu.style.top = `${viewportHeight - rect.height - 8}px`; + } + }, [position]); + + const style = useMemo(() => ({ left: position.x, top: position.y }), [position]); + + return ( + + {items.map((item, index) => { + if (isDivider(item)) { + const k = `divider-${index}`; + return
; + } + + return ; + })} + + ); +} + +export function ContextMenu({ isOpen, position, onClose, items }: ContextMenuProps) { + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (e: MouseEvent | TouchEvent) => { + const target = e.target; + if (target instanceof HTMLElement && target.closest("[role=\"menu\"]")) return; + onClose(); + }; + + document.addEventListener("click", handleClickOutside); + document.addEventListener("contextmenu", handleClickOutside); + + return () => { + document.removeEventListener("click", handleClickOutside); + document.removeEventListener("contextmenu", handleClickOutside); + }; + }, [isOpen, onClose]); + + return ( + + {isOpen && } + + ); +} + +export function useContextMenu() { + const [state, setState] = useState<{ isOpen: boolean; position: { x: number; y: number } }>({ + isOpen: false, + position: { x: 0, y: 0 }, + }); + + const open = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setState({ isOpen: true, position: { x: e.clientX, y: e.clientY } }); + }, []); + + const close = useCallback(() => { + setState((prev) => ({ ...prev, isOpen: false })); + }, []); + + return { ...state, open, close }; +} diff --git a/src/components/ContextMenu/index.ts b/src/components/ContextMenu/index.ts new file mode 100644 index 0000000..b82583d --- /dev/null +++ b/src/components/ContextMenu/index.ts @@ -0,0 +1,2 @@ +export { ContextMenu, useContextMenu } from "./ContextMenu"; +export type { ContextMenuDivider, ContextMenuItem, ContextMenuProps } from "./ContextMenu"; diff --git a/src/components/DocumentTabs/DocumentTabs.tsx b/src/components/DocumentTabs/DocumentTabs.tsx index e5c5961..5e1989f 100644 --- a/src/components/DocumentTabs/DocumentTabs.tsx +++ b/src/components/DocumentTabs/DocumentTabs.tsx @@ -1,59 +1,13 @@ -import { Button } from "$components/Button"; +import { ContextMenu, useContextMenu } from "$components/ContextMenu"; import { EMPTY_NEW_DOC_TRANSITION, NO_MOTION_TRANSITION } from "$constants"; import { useWorkspaceController } from "$hooks/controllers/useWorkspaceController"; import { useSkipAnimation } from "$hooks/useMotion"; import { useViewportTier } from "$hooks/useViewportTier"; import { useTabsState, useWorkspaceLocationsState } from "$state/selectors"; -import { AnimatePresence, motion } from "motion/react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { DocumentTab } from "./DocumentTab"; import { NewButton } from "./NewButton"; -const CONTEXT_MENU_INITIAL = { opacity: 0, scale: 0.96 }; -const CONTEXT_MENU_ANIMATE = { opacity: 1, scale: 1 }; -const CONTEXT_MENU_EXIT = { opacity: 0, scale: 0.96 }; -const CONTEXT_MENU_TRANSITION = { duration: 0.12, ease: "easeOut" as const }; - -type ContextMenuProps = { - style: React.CSSProperties; - close: () => void; - closeOthers: () => void; - closeAll: () => void; -}; - -function ContextMenu({ style, close, closeOthers, closeAll }: ContextMenuProps) { - const { isNarrow } = useViewportTier(); - const skipAnimation = useSkipAnimation(); - const transition = useMemo(() => skipAnimation ? NO_MOTION_TRANSITION : CONTEXT_MENU_TRANSITION, [skipAnimation]); - return ( - - - - - - ); -} - export type DocumentTabsProps = { onNewDocument?: () => void }; export function DocumentTabs({ onNewDocument }: DocumentTabsProps) { @@ -64,18 +18,13 @@ export function DocumentTabs({ onNewDocument }: DocumentTabsProps) { const skipAnimation = useSkipAnimation(); const [draggingTab, setDraggingTab] = useState(null); const [dragOverTab, setDragOverTab] = useState(null); - const [contextMenu, setContextMenu] = useState<{ tabId: string; x: number; y: number } | null>(null); + const [contextMenuTabId, setContextMenuTabId] = useState(null); const tabsRef = useRef(null); - const transition = useMemo(() => skipAnimation ? NO_MOTION_TRANSITION : EMPTY_NEW_DOC_TRANSITION, [skipAnimation]); + const { isOpen: isContextMenuOpen, position: contextMenuPosition, open: openContextMenu, close: closeContextMenu } = + useContextMenu(); - useEffect(() => { - const handleClickOutside = () => setContextMenu(null); - if (contextMenu) { - document.addEventListener("click", handleClickOutside); - return () => document.removeEventListener("click", handleClickOutside); - } - }, [contextMenu]); + const transition = useMemo(() => skipAnimation ? NO_MOTION_TRANSITION : EMPTY_NEW_DOC_TRANSITION, [skipAnimation]); const handleDragStart = useCallback((e: React.DragEvent, tabId: string) => { setDraggingTab(tabId); @@ -106,38 +55,39 @@ export function DocumentTabs({ onNewDocument }: DocumentTabsProps) { }, [draggingTab, handleReorderTabs, tabs]); const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => { - e.preventDefault(); - setContextMenu({ tabId, x: e.clientX, y: e.clientY }); - }, []); + setContextMenuTabId(tabId); + openContextMenu(e); + }, [openContextMenu]); - const closeContextMenu = useCallback(() => { - if (contextMenu) { - handleCloseTab(contextMenu.tabId); - setContextMenu(null); + const closeCurrentTab = useCallback(() => { + if (contextMenuTabId) { + handleCloseTab(contextMenuTabId); } - }, [contextMenu, handleCloseTab]); + }, [contextMenuTabId, handleCloseTab]); - const closeOthers = useCallback(() => { - if (contextMenu) { + const closeOtherTabs = useCallback(() => { + if (contextMenuTabId) { for (const t of tabs) { - if (t.id !== contextMenu.tabId) { + if (t.id !== contextMenuTabId) { handleCloseTab(t.id); } } - setContextMenu(null); } - }, [contextMenu, handleCloseTab, tabs]); + }, [contextMenuTabId, handleCloseTab, tabs]); - const closeAll = useCallback(() => { + const closeAllTabs = useCallback(() => { for (const t of tabs) { handleCloseTab(t.id); } - setContextMenu(null); }, [tabs, handleCloseTab]); - const contextMenuStyle = useMemo(() => contextMenu ? ({ left: contextMenu.x, top: contextMenu.y }) : {}, [ - contextMenu, - ]); + const contextMenuItems = useMemo( + () => [{ label: "Close", onClick: closeCurrentTab }, { label: "Close Others", onClick: closeOtherTabs }, { + label: "Close All", + onClick: closeAllTabs, + }], + [closeCurrentTab, closeOtherTabs, closeAllTabs], + ); const compactTabs = useMemo(() => isCompact || viewportWidth < 860, [isCompact, viewportWidth]); @@ -177,15 +127,11 @@ export function DocumentTabs({ onNewDocument }: DocumentTabsProps) { ))} - - {contextMenu && ( - - )} - +
); } diff --git a/src/components/Sidebar/DocumentItem.tsx b/src/components/Sidebar/DocumentItem.tsx index 61d1c23..a8a658f 100644 --- a/src/components/Sidebar/DocumentItem.tsx +++ b/src/components/Sidebar/DocumentItem.tsx @@ -1,6 +1,8 @@ -import { FileTextIcon } from "$icons"; +import { ContextMenu, ContextMenuDivider, ContextMenuItem, useContextMenu } from "$components/ContextMenu"; +import { ClipboardIcon, FileTextIcon, TrashIcon } from "$icons"; +import { logger } from "$logger"; import type { DocMeta } from "$types"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { TreeItem } from "./TreeItem"; const fileTextIcon = { Component: FileTextIcon, size: "sm" as const }; @@ -14,14 +16,53 @@ export function DocumentItem( id: number; }, ) { + const { isOpen, position, open, close } = useContextMenu(); + const handleClick = useCallback(() => onSelectDocument(id, doc.rel_path), [id, onSelectDocument, doc.rel_path]); + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + open(e); + }, [open]); + + const handleOpen = useCallback(() => { + onSelectDocument(id, doc.rel_path); + }, [id, doc.rel_path, onSelectDocument]); + + const handleCopyPath = useCallback(async () => { + try { + await navigator.clipboard.writeText(doc.rel_path); + } catch (error) { + logger.error("Failed to copy path to clipboard", { error }); + } + }, [doc.rel_path]); + + const handleDelete = useCallback(() => { + logger.info("Delete document requested", { locationId: id, path: doc.rel_path }); + }, [id, doc.rel_path]); + + const contextMenuItems = useMemo<(ContextMenuItem | ContextMenuDivider)[]>( + () => [ + { label: "Open", onClick: handleOpen, icon: }, + { label: "Copy Path", onClick: handleCopyPath, icon: }, + { divider: true }, + { label: "Delete", onClick: handleDelete, icon: , danger: true }, + ], + [handleOpen, handleCopyPath, handleDelete], + ); + return ( - + <> + + + ); } diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx index dfbcbcf..8729cae 100644 --- a/src/components/Sidebar/Sidebar.tsx +++ b/src/components/Sidebar/Sidebar.tsx @@ -168,6 +168,7 @@ export function Sidebar({ onNewDocument }: SidebarProps) { onSelect={selectLocation} onToggle={toggleLocation} onRemove={handleRemoveLocation} + onRefresh={handleRefreshSidebar} onSelectDocument={handleSelectDocument} setShowLocationMenu={setShowLocationMenu} isMenuOpen={showLocationMenu === location.id} diff --git a/src/components/Sidebar/SidebarLocationItem.tsx b/src/components/Sidebar/SidebarLocationItem.tsx index a0ef279..a6aeb38 100644 --- a/src/components/Sidebar/SidebarLocationItem.tsx +++ b/src/components/Sidebar/SidebarLocationItem.tsx @@ -1,5 +1,6 @@ import { Button } from "$components/Button"; -import { FolderIcon, MoreVerticalIcon, RefreshIcon } from "$icons"; +import { ContextMenu, ContextMenuDivider, ContextMenuItem, useContextMenu } from "$components/ContextMenu"; +import { FolderIcon, MoreVerticalIcon, RefreshIcon, TrashIcon } from "$icons"; import type { SidebarRefreshReason } from "$state/types"; import { DocMeta, LocationDescriptor } from "$types"; import type { Dispatch, MouseEventHandler, SetStateAction } from "react"; @@ -11,31 +12,62 @@ import { TreeItem } from "./TreeItem"; const folderIcon = { Component: FolderIcon, size: "md" as const }; -const FolderItem = ( - { name, isSelected, selectedDocPath, isExpanded, onItemClick, onToggleClick, actionProps }: { - name: string; - isSelected: boolean; - selectedDocPath?: string; - isExpanded: boolean; - onItemClick: () => void; - onToggleClick: () => void; - actionProps: LocationActionProps; - }, -) => ( -
- - - -
-); +type FolderItemProps = { + name: string; + isSelected: boolean; + selectedDocPath?: string; + isExpanded: boolean; + isRefreshing: boolean; + onItemClick: () => void; + onToggleClick: () => void; + onRefresh: () => void; + actionProps: LocationActionProps; +}; + +function FolderItem( + { name, isSelected, selectedDocPath, isExpanded, isRefreshing, onItemClick, onToggleClick, onRefresh, actionProps }: + FolderItemProps, +) { + const { isOpen, position, open, close } = useContextMenu(); + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + open(e); + }, [open]); + + const contextMenuItems = useMemo<(ContextMenuItem | ContextMenuDivider)[]>( + () => [{ label: "Refresh", onClick: onRefresh, icon: , disabled: isRefreshing }, { + divider: true, + }, { + label: "Remove Location", + onClick: actionProps.handleRemoveClick, + icon: , + danger: true, + }], + [onRefresh, isRefreshing, actionProps.handleRemoveClick], + ); + + return ( + <> +
+ + + +
+ + + ); +} type LocationActionProps = { isMenuOpen: boolean; @@ -70,6 +102,7 @@ type SidebarLocationItemProps = { onSelect: (id: number) => void; onToggle: (id: number) => void; onRemove: (id: number) => void; + onRefresh: (id: number) => void; onSelectDocument: (id: number, path: string) => void; setShowLocationMenu: Dispatch>; documents: DocMeta[]; @@ -88,6 +121,7 @@ function SidebarLocationItemComponent( onSelect, onToggle, onRemove, + onRefresh, onSelectDocument, setShowLocationMenu, documents, @@ -107,6 +141,10 @@ function SidebarLocationItemComponent( setShowLocationMenu((current) => current === location.id ? null : location.id); }, [location.id, setShowLocationMenu]); + const handleRefresh = useCallback(() => { + onRefresh(location.id); + }, [location.id, onRefresh]); + const onItemClick = useCallback(() => { onSelect(location.id); }, [location.id, onSelect]); @@ -128,8 +166,10 @@ function SidebarLocationItemComponent( isSelected={isSelected && !selectedDocPath} selectedDocPath={selectedDocPath} isExpanded={isExpanded} + isRefreshing={isRefreshing} onItemClick={onItemClick} onToggleClick={onToggleClick} + onRefresh={handleRefresh} actionProps={actionProps} /> {isExpanded && isSelected && ( diff --git a/src/components/Sidebar/TreeItem.tsx b/src/components/Sidebar/TreeItem.tsx index fc02df4..57f62d2 100644 --- a/src/components/Sidebar/TreeItem.tsx +++ b/src/components/Sidebar/TreeItem.tsx @@ -14,6 +14,7 @@ type TreeItemProps = { level?: number; onClick?: () => void; onToggle?: () => void; + onContextMenu?: MouseEventHandler; children?: React.ReactNode; }; @@ -27,6 +28,7 @@ export function TreeItem( level = 0, onClick, onToggle, + onContextMenu, children, }: TreeItemProps, ) { @@ -81,6 +83,7 @@ export function TreeItem( className={containerClasses} style={containerStyle} onClick={onClick} + onContextMenu={onContextMenu} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}> {hasChildItems diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 28cbab2..838f4f1 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -150,3 +150,33 @@ export const ExpandIcon = ({ size = "md", className = "", style }: IconProps) => ); + +export const EditIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + +); + +export const CopyIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + +); + +export const ClipboardIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + +); + +export const ExternalLinkIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + +); + +export const PenIcon = ({ size = "md", className = "", style }: IconProps) => ( + + + +); diff --git a/src/components/layout/AppHeaderBar.tsx b/src/components/layout/AppHeaderBar.tsx index f477dad..33990dd 100644 --- a/src/components/layout/AppHeaderBar.tsx +++ b/src/components/layout/AppHeaderBar.tsx @@ -1,13 +1,13 @@ import { Button } from "$components/Button"; import { useViewportTier } from "$hooks/useViewportTier"; -import { ChevronDownIcon, LibraryIcon, SearchIcon } from "$icons"; +import { ChevronDownIcon, PenIcon, SearchIcon } from "$icons"; import { useAppHeaderBarState } from "$state/selectors"; import { useCallback, useMemo } from "react"; const AppTitle = ({ hideTitle }: { hideTitle: boolean }) => (
- +
{hideTitle ? null :

Writer

}
-- 2.51.2