diff --git a/src/components/ai-elements/file-tree.test.tsx b/src/components/ai-elements/file-tree.test.tsx index aa6d0da8e..227cd635e 100644 --- a/src/components/ai-elements/file-tree.test.tsx +++ b/src/components/ai-elements/file-tree.test.tsx @@ -1,5 +1,7 @@ -import { render, screen } from "@testing-library/react" -import { describe, expect, it } from "vitest" +import { fireEvent, render, screen } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { resolveFileTreeDropZone } from "@/lib/file-tree-dnd" import { FileTree, FileTreeFile, FileTreeFolder } from "./file-tree" @@ -59,3 +61,143 @@ describe("FileTree keyboard focus topology", () => { expect(container).not.toHaveAttribute("aria-activedescendant") }) }) + +describe("FileTree trailing row actions", () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + const onDragOver = vi.fn() + + function renderWithActions() { + onDragOver.mockClear() + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + const view = render( + + + ⋯ + + } + > + + ⋯ + + } + /> + + + ) + return { ...view, consoleError } + } + + it("keeps the folder's action out of the header button", () => { + // The folder header is a native + } + /> + + ) + + const action = screen.getByLabelText("dimmed action") + expect(action.closest(".opacity-70")).not.toBeNull() + }) + + it("publishes the row-hover group both rows' actions can reveal from", () => { + // The action is hidden at rest and revealed on row hover; :hover only + // propagates to ancestors, so the group has to sit on an element that + // encloses BOTH the row content and the action. + const { container } = renderWithActions() + + for (const action of [ + screen.getByLabelText("folder action"), + screen.getByLabelText("file action"), + ]) { + const group = action.closest(".group\\/file-tree-row") + expect(group).not.toBeNull() + expect(container.contains(group)).toBe(true) + } + }) +}) diff --git a/src/components/ai-elements/file-tree.tsx b/src/components/ai-elements/file-tree.tsx index ce839f8d3..2cfe1c12c 100644 --- a/src/components/ai-elements/file-tree.tsx +++ b/src/components/ai-elements/file-tree.tsx @@ -1,12 +1,6 @@ "use client" -import type { - ButtonHTMLAttributes, - CSSProperties, - HTMLAttributes, - ReactNode, - Ref, -} from "react" +import type { CSSProperties, HTMLAttributes, ReactNode, Ref } from "react" import { Collapsible, @@ -85,6 +79,19 @@ const FileTreeContext = createContext({ keyboardNavigation: false, }) +/** + * True when the enclosing tree runs in roving-focus mode, i.e. the container is + * the single tab stop and rows are driven by `aria-activedescendant`. + * + * Trailing row widgets (see {@link FileTreeFolderProps.actions}) must read this + * and take themselves out of the tab order: a natively focusable widget on every + * row would put the tree's whole row count back into the tab sequence, and the + * arrow keys would land on the widget instead of the container that owns them. + */ +export function useFileTreeRovingFocus(): boolean { + return useContext(FileTreeContext).keyboardNavigation +} + export type FileTreeProps = Omit, "onSelect"> & { expanded?: Set defaultExpanded?: Set @@ -191,13 +198,31 @@ export type FileTreeFolderProps = HTMLAttributes & { suffix?: ReactNode suffixClassName?: string /** - * Props applied to the folder's header row (the trigger button) — e.g. - * `draggable` and drag/drop handlers for file-tree DnD. Placed on the header - * (not the outer wrapper, which also contains the child rows) so a drop - * targets THIS folder rather than its whole subtree. `onClick`/`type` are - * owned by the folder and are not overridable here. + * Right-aligned trailing widget (e.g. a "more" menu button). Rendered as a + * SIBLING of the header button — HTML forbids a button inside a button, and + * React reports the nesting as a hydration error — inside a `FileTreeActions` + * wrapper so click/keydown don't bubble up to the row's own handlers (e.g. + * the expand/collapse click). Clicks on the widget are the caller's + * responsibility. Passing this switches the header to a wrapped layout; rows + * without actions render exactly as before. + * + * Both row kinds carry the `file-tree-row` Tailwind group, so a widget can + * reveal itself on row hover with `group-hover/file-tree-row:…`. + */ + actions?: ReactNode + /** + * Props applied to the folder's header row — e.g. `draggable` and drag/drop + * handlers for file-tree DnD. Placed on the header (not the outer wrapper, + * which also contains the child rows) so a drop targets THIS folder rather + * than its whole subtree. `onClick`/`type` are owned by the folder and are + * not overridable here. + * + * The header row is the ` + + ) + return ( @@ -274,65 +375,22 @@ export const FileTreeFolder = ({ tabIndex={keyboardNavigation ? -1 : 0} {...props} > - - - + {header} + {actions} + + ) : ( + header + )} {/* With explicit `depth`, descendants indent themselves via padding, so this wrapper adds NO left inset (keeping their @@ -365,6 +423,14 @@ export type FileTreeFileProps = HTMLAttributes & { /** Nesting depth (0 = top level). See {@link FileTreeFolderProps.depth}: when * provided the row is full-width and indents its content via padding. */ depth?: number + /** + * Right-aligned trailing widget (e.g. a "more" menu button). Rendered + * inside a `FileTreeActions` wrapper so click/keydown don't bubble up to + * the row's own handlers (e.g. opening a file preview). Clicks on the + * widget are the caller's responsibility. See + * {@link FileTreeFolderProps.actions} for the row-hover group name. + */ + actions?: ReactNode } export const FileTreeFile = ({ @@ -374,6 +440,7 @@ export const FileTreeFile = ({ depth, className, style, + actions, children, ...props }: FileTreeFileProps) => { @@ -400,7 +467,7 @@ export const FileTreeFile = ({
{name} )} + {actions ? {actions} : null}
) diff --git a/src/components/layout/aux-panel-file-tree-tab-source.test.ts b/src/components/layout/aux-panel-file-tree-tab-source.test.ts index db9c66c9b..5d74eb786 100644 --- a/src/components/layout/aux-panel-file-tree-tab-source.test.ts +++ b/src/components/layout/aux-panel-file-tree-tab-source.test.ts @@ -61,6 +61,48 @@ describe("aux file tree badges links from the node's own symlink flag", () => { }) }) +describe("aux file tree row context menus stay reachable", () => { + // Radix's `asChild` clones the child ELEMENT and hands it the trigger's + // props. A child that drops unknown props — a Context.Provider, or a + // component that doesn't spread `...props` — leaves the trigger with no DOM + // element at all: no listener, no menu, on right-click, long-press, or the + // row's ⋯ button. That failure is silent, so lock the two shapes it needs. + it("never hands an asChild trigger a context provider", () => { + expect(auxSource).not.toMatch( + /]*asChild[^>]*>\s*(\{\/\*[\s\S]*?\*\/\}\s*)?<[A-Z][\w]*\.Provider\b/ + ) + }) + + it("gives the workspace-root trigger the row component itself", () => { + expect(auxSource).toMatch( + /\s* { + // A folder's trigger encloses its expanded descendants' triggers, so ONE + // touch pointerdown reaches every ancestor's copy of the hook. Each arms + // its own timer and each dispatches its own contextmenu from its OWN + // element, so long-pressing a nested file opened its menu AND both + // ancestors' — the outermost winning the screen. Radix's built-in + // long-press survives nesting because all the triggers share one bubbling + // event that the innermost `preventDefault`s; separate dispatches have no + // such interlock. The ⋯ button is the touch entry point instead. + expect(auxSource).not.toMatch(/useLongPressToOpenMenu/) + expect(auxSource).not.toMatch(/longPressHandlers/) + }) + + it("forwards the trigger's props through RootDropFolder onto the row", () => { + const start = auxSource.indexOf("function RootDropFolder(") + expect(start).toBeGreaterThan(-1) + const body = auxSource.slice(start, start + 1200) + // Collected off the signature... + expect(body).toMatch(/\.\.\.props\s*\n\s*\}:/) + // ...and spread onto the FileTreeFolder that renders the row's div. + expect(body).toMatch(/ { it("offers VS Code next to Explorer and Terminal", () => { expect(auxSource).toMatch(/OpenInSubContent/) diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx index beb9a76a0..d416d49d2 100644 --- a/src/components/layout/aux-panel-file-tree-tab.tsx +++ b/src/components/layout/aux-panel-file-tree-tab.tsx @@ -9,6 +9,7 @@ import { useMemo, useRef, useState, + type HTMLAttributes, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, } from "react" @@ -33,6 +34,7 @@ import { AuxPanelNoFolderEmpty } from "@/components/layout/aux-panel-no-folder-e import { WorkspaceDegradedBanner } from "@/components/layout/workspace-degraded-banner" import { WorkspaceUploadDialog } from "@/components/layout/workspace-upload-dialog" import { OpenInSubContent } from "@/components/layout/open-in-menu" +import { RowMoreButton } from "@/components/layout/row-more-button" import { createFileTreeEntry, deleteFileTreeEntry, @@ -564,20 +566,27 @@ function RootDropFolder({ name, dnd, children, + ...props }: { name: string dnd: TreeDndHandlers children: ReactNode -}) { +} & HTMLAttributes) { const [dropActive, setDropActive] = useState(false) // On desktop the DOM dragover never reaches this row, so also honor the // native-drag highlight broadcast for the workspace root (""). const desktopDropActive = useContext(DesktopDropDirContext) === "" return ( } dropActive={dropActive || desktopDropActive} dropTargetDir="" depth={0} @@ -736,7 +745,15 @@ function RenderNode({ return ( - + {/* + asChild merges the Radix trigger's pointerdown / contextmenu handlers + and its `WebkitTouchCallout: none` style onto the row's own div + instead of wrapping it in a whose inline box breaks the row's + `w-max min-w-full` sizing. Matches every other ContextMenuTrigger in + the codebase; see the RootDropFolder wrapper below for the one rule + asChild imposes on the child. + */} + } /> @@ -915,10 +933,16 @@ function RenderNode({ return ( - + {/* + asChild merges the Radix trigger's pointerdown / contextmenu handlers + and its `WebkitTouchCallout: none` style onto the FileTreeFolder's own + div — same reasoning as the FileTreeFile wrapper above. + */} + } suffix={ isLinkedDir ? ( {folder?.path && ( - - - + + + {/* + asChild merges the Radix trigger's pointerdown / + contextmenu handlers and its `WebkitTouchCallout: none` + style onto the row itself instead of a wrapper , + matching every other ContextMenuTrigger in the codebase. + + The child MUST be a component that forwards the props it + is handed down to a real DOM element. Radix's Slot only + clones the child element — hand it a Context.Provider (or + any component that drops unknown props) and the trigger + renders NOTHING: no listener, no menu, on right-click or + long-press or the ⋯ button. Hence the provider sits + outside, and RootDropFolder spreads `...props`. + */} + {nodes.map((node) => ( ))} - - - - - {t("new")} - - handleRequestCreate("", "file")} - > - {t("newFile")} - - handleRequestCreate("", "dir")} - > - {t("newDirectory")} - - - - - - {t("git")} - - - handleOpenCommitWindow()} - disabled={!gitEnabled} - > - {t("actions.commitCode")} - - void handleAddToVcs(rootTarget)} - disabled={!gitEnabled} - > - {t("actions.addToVcs")} - - - void openWorkingTreeDiff(".", { - mode: "overview", - }) - } - disabled={!gitEnabled} - > - {tCommon("viewDiff")} - - - handleRequestCompareWithBranch(rootTarget) - } - disabled={!gitEnabled} - > - {t("compareWithBranch")} - - handleRequestRollback(rootTarget)} - disabled={!gitEnabled} - > - {t("actions.rollback")} - - - - { - void fetchTree() - }} - > - {t("reloadFromDisk")} - - - - {t("openIn")} - - { - void revealItemInDir(folder.path) - }} - onOpenTerminal={() => { - void handleOpenDirInTerminal( - folder.path, - rootNodeName - ) + + + + + {t("new")} + + + handleRequestCreate("", "file")} + > + {t("newFile")} + + handleRequestCreate("", "dir")} + > + {t("newDirectory")} + + + + + + {t("git")} + + + handleOpenCommitWindow()} + disabled={!gitEnabled} + > + {t("actions.commitCode")} + + void handleAddToVcs(rootTarget)} + disabled={!gitEnabled} + > + {t("actions.addToVcs")} + + + void openWorkingTreeDiff(".", { + mode: "overview", + }) + } + disabled={!gitEnabled} + > + {tCommon("viewDiff")} + + + handleRequestCompareWithBranch(rootTarget) + } + disabled={!gitEnabled} + > + {t("compareWithBranch")} + + handleRequestRollback(rootTarget)} + disabled={!gitEnabled} + > + {t("actions.rollback")} + + + + { + void fetchTree() }} - onOpenCode={() => { - void openInCode(folder.path).catch((error) => { - toast.error(t("toasts.openInCodeFailed"), { - description: toErrorMessage(error), + > + {t("reloadFromDisk")} + + + + {t("openIn")} + + { + void revealItemInDir(folder.path) + }} + onOpenTerminal={() => { + void handleOpenDirInTerminal( + folder.path, + rootNodeName + ) + }} + onOpenCode={() => { + void openInCode(folder.path).catch((error) => { + toast.error(t("toasts.openInCodeFailed"), { + description: toErrorMessage(error), + }) }) + }} + /> + + + void copyPathToClipboard(folder.path, { + success: t("toasts.pathCopied"), + failure: t("toasts.copyPathFailed"), }) - }} - /> - - - void copyPathToClipboard(folder.path, { - success: t("toasts.pathCopied"), - failure: t("toasts.copyPathFailed"), - }) - } - > - {t("copyPath")} - - {webMode && ( - <> - handleRequestUpload("")} - > - {t("upload")} - - - void handleRequestDownloadDir(rootTarget) - } - > - {t("downloadAsZip")} - - - )} - - + } + > + {t("copyPath")} + + {webMode && ( + <> + handleRequestUpload("")} + > + {t("upload")} + + + void handleRequestDownloadDir(rootTarget) + } + > + {t("downloadAsZip")} + + + )} + + + )} diff --git a/src/components/layout/row-more-button.test.tsx b/src/components/layout/row-more-button.test.tsx new file mode 100644 index 000000000..cadfce9b1 --- /dev/null +++ b/src/components/layout/row-more-button.test.tsx @@ -0,0 +1,137 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import type { ReactNode } from "react" +import { afterEach, describe, expect, it, vi } from "vitest" + +// `next-intl`'s `useTranslations` returns the leaf string for the requested +// key. Stub it to a fixed value so the tests only check button behaviour, not +// translation plumbing. +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => `tr:${key}`, +})) + +import { + FileTree, + FileTreeFile, + FileTreeFolder, +} from "@/components/ai-elements/file-tree" +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu" + +import { RowMoreButton } from "./row-more-button" + +/** + * The button only makes sense inside the thing it opens, so every test renders + * a real Radix `ContextMenu` around a real file-tree row — the same wiring the + * file tree uses. A test that fires at a bare `
` cannot tell a working + * button from one whose trigger never made it into the DOM. + */ +function renderRow( + row: (actions: ReactNode) => ReactNode, + options: { keyboardNavigation?: boolean } = {} +) { + // `onSelect` is what a click on the row itself fires (open the preview / + // select the folder) — the handler the button must not leak into. + const onRowSelect = vi.fn() + render( + + + + {row()} + + + rename + + + + ) + return { button: screen.getByLabelText("tr:moreActions"), onRowSelect } +} + +const fileRow = (actions: ReactNode) => ( + +) + +const folderRow = (actions: ReactNode) => ( + +) + +function openMenuTexts(): string[] { + return [...document.querySelectorAll("[data-slot=context-menu-content]")].map( + (node) => node.textContent ?? "" + ) +} + +describe("RowMoreButton", () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it("renders a labelled menu button with the icon hidden from AT", () => { + const { button } = renderRow(fileRow) + expect(button.tagName).toBe("BUTTON") + expect(button).toHaveAttribute("aria-label", "tr:moreActions") + expect(button).toHaveAttribute("aria-haspopup", "menu") + expect(button.querySelector("svg")).toHaveAttribute("aria-hidden") + }) + + it.each([ + ["file", fileRow], + ["folder", folderRow], + ])("opens the row's own context menu on a %s row", (_kind, row) => { + const { button } = renderRow(row) + expect(openMenuTexts()).toEqual([]) + fireEvent.click(button) + expect(openMenuTexts()).toEqual(["rename"]) + }) + + it("anchors the menu at the button's box, not at the click point", () => { + const { button } = renderRow(fileRow) + vi.spyOn(button, "getBoundingClientRect").mockReturnValue({ + bottom: 48, + left: 120, + } as DOMRect) + const seen = vi.fn() + button.addEventListener("contextmenu", seen as EventListener) + + // A keyboard activation (Enter/Space on a focused button) reports + // clientX/clientY as 0 — anchoring on those would park the menu in the + // viewport's top-left corner instead of next to the row. + fireEvent.click(button, { clientX: 0, clientY: 0 }) + + const event = seen.mock.calls[0][0] as MouseEvent + expect(event.type).toBe("contextmenu") + expect(event.button).toBe(2) + expect(event.bubbles).toBe(true) + expect(event.cancelable).toBe(true) + expect([event.clientX, event.clientY]).toEqual([120, 48]) + }) + + it.each([ + ["file", fileRow], + ["folder", folderRow], + ])("does not leak the click into the %s row's own handler", (_kind, row) => { + const { button, onRowSelect } = renderRow(row) + fireEvent.click(button) + expect(onRowSelect).not.toHaveBeenCalled() + }) + + it("stays out of the tab order inside a roving-focus tree", () => { + // The tree container is the single tab stop and owns the arrow keys; one + // focusable widget per row would put every row back in the tab sequence. + const { button } = renderRow(fileRow, { keyboardNavigation: true }) + expect(button.tabIndex).toBe(-1) + }) + + it("keeps its default tab stop in trees without roving focus", () => { + const { button } = renderRow(fileRow) + expect(button.tabIndex).toBe(0) + }) +}) diff --git a/src/components/layout/row-more-button.tsx b/src/components/layout/row-more-button.tsx new file mode 100644 index 000000000..acccb1c72 --- /dev/null +++ b/src/components/layout/row-more-button.tsx @@ -0,0 +1,71 @@ +"use client" + +import { MoreHorizontal } from "lucide-react" +import type { MouseEvent as ReactMouseEvent } from "react" +import { useTranslations } from "next-intl" + +import { useFileTreeRovingFocus } from "@/components/ai-elements/file-tree" +import { cn } from "@/lib/utils" + +interface RowMoreButtonProps { + /** Optional className overrides. */ + className?: string +} + +/** + * Tiny horizontal-three-dots button rendered on the right of a tree row. + * Clicking it dispatches a synthetic `contextmenu` MouseEvent that bubbles to + * the enclosing Radix `ContextMenuTrigger`, which opens the very same menu + * right-click opens — one source of truth, nothing duplicated. Same trick as + * the sidebar conversation row's ⋯ button. + * + * The menu is anchored at the button's own box rather than at the click point: + * a keyboard or programmatic activation reports `clientX/clientY` as 0, which + * would park the menu in the viewport's top-left corner. + * + * The click is `stopPropagation`-ed so it doesn't fire the row's own `onClick` + * (which would open the file preview / toggle the folder). + * + * Hidden at rest on pointer devices — right-click is the primary affordance + * there and one ⋯ per file-tree row is a lot of ink. Pinned visible where there + * is no hover to reveal it, which is exactly the touch case this exists for. + */ +export function RowMoreButton({ className }: RowMoreButtonProps) { + const t = useTranslations("Folder.fileTreeTab") + // In roving-focus trees the container is the single tab stop; a focusable + // widget per row would break that (and `FileTreeActions` swallows keydown, so + // the arrow keys would die on it too). + const rovingFocus = useFileTreeRovingFocus() + return ( + + ) +} diff --git a/src/hooks/use-long-press-to-open-menu.test.tsx b/src/hooks/use-long-press-to-open-menu.test.tsx new file mode 100644 index 000000000..33ea125d3 --- /dev/null +++ b/src/hooks/use-long-press-to-open-menu.test.tsx @@ -0,0 +1,225 @@ +import { act, render } from "@testing-library/react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { useLongPressToOpenMenu } from "./use-long-press-to-open-menu" + +/** + * jsdom's `fireEvent.pointerDown` drops `pointerType` (it builds a plain + * MouseEvent), so the tests below construct MouseEvent objects directly and + * attach `pointerType` via `Object.defineProperty` — same pattern as + * chat-input.test.tsx. + */ +function makePointerEvent( + type: "pointerdown" | "pointermove" | "pointerup" | "pointercancel", + target: Element, + init: { + clientX?: number + clientY?: number + pointerType: "mouse" | "touch" | "pen" + } +) { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: init.clientX, + clientY: init.clientY, + }) + Object.defineProperty(event, "pointerType", { value: init.pointerType }) + target.dispatchEvent(event) + return event +} + +/** Advance vi's fake timers and flush React state queued by their callbacks. */ +function advance(ms: number) { + act(() => { + vi.advanceTimersByTime(ms) + }) +} + +interface Fixture { + host: HTMLElement + onContextMenu: ReturnType +} + +function renderHost( + options?: Parameters[0] +): Fixture { + const onContextMenu = vi.fn() + function Harness() { + const gesture = useLongPressToOpenMenu(options) + return
+ } + const utils = render() + const host = utils.container.querySelector( + "[data-testid='host']" + ) as HTMLElement + return { host, onContextMenu } +} + +describe("useLongPressToOpenMenu", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("ignores mouse pointerdown entirely", () => { + const { host, onContextMenu } = renderHost() + makePointerEvent("pointerdown", host, { + pointerType: "mouse", + clientX: 10, + clientY: 20, + }) + advance(1000) + expect(onContextMenu).not.toHaveBeenCalled() + }) + + it("dispatches a synthetic contextmenu after longPressMs of a still touch", () => { + const { host, onContextMenu } = renderHost() + makePointerEvent("pointerdown", host, { + pointerType: "touch", + clientX: 50, + clientY: 60, + }) + advance(499) + expect(onContextMenu).not.toHaveBeenCalled() + + advance(1) + expect(onContextMenu).toHaveBeenCalledTimes(1) + const event = onContextMenu.mock.calls[0][0] as MouseEvent + expect(event.button).toBe(2) + expect(event.clientX).toBe(50) + expect(event.clientY).toBe(60) + expect(event.bubbles).toBe(true) + expect(event.cancelable).toBe(true) + }) + + it("also opens on a pen pointer", () => { + const { host, onContextMenu } = renderHost() + makePointerEvent("pointerdown", host, { + pointerType: "pen", + clientX: 5, + clientY: 5, + }) + advance(500) + expect(onContextMenu).toHaveBeenCalledTimes(1) + }) + + it("cancels when the touch moves past the move threshold", () => { + const { host, onContextMenu } = renderHost() + makePointerEvent("pointerdown", host, { + pointerType: "touch", + clientX: 100, + clientY: 100, + }) + advance(300) + makePointerEvent("pointermove", host, { + pointerType: "touch", + clientX: 120, + clientY: 100, + }) + advance(500) + expect(onContextMenu).not.toHaveBeenCalled() + }) + + it("tolerates micro-moves under the threshold (a still touch)", () => { + const { host, onContextMenu } = renderHost() + makePointerEvent("pointerdown", host, { + pointerType: "touch", + clientX: 100, + clientY: 100, + }) + advance(200) + makePointerEvent("pointermove", host, { + pointerType: "touch", + clientX: 103, + clientY: 101, + }) + makePointerEvent("pointermove", host, { + pointerType: "touch", + clientX: 105, + clientY: 99, + }) + advance(300) + expect(onContextMenu).toHaveBeenCalledTimes(1) + }) + + it("cancels on pointerup", () => { + const { host, onContextMenu } = renderHost() + makePointerEvent("pointerdown", host, { + pointerType: "touch", + clientX: 0, + clientY: 0, + }) + advance(200) + makePointerEvent("pointerup", host, { pointerType: "touch" }) + advance(500) + expect(onContextMenu).not.toHaveBeenCalled() + }) + + it("cancels on pointercancel", () => { + const { host, onContextMenu } = renderHost() + makePointerEvent("pointerdown", host, { + pointerType: "touch", + clientX: 0, + clientY: 0, + }) + advance(200) + makePointerEvent("pointercancel", host, { pointerType: "touch" }) + advance(500) + expect(onContextMenu).not.toHaveBeenCalled() + }) + + it("a second touch during a still hold resets the timer", () => { + const { host, onContextMenu } = renderHost() + makePointerEvent("pointerdown", host, { + pointerType: "touch", + clientX: 0, + clientY: 0, + }) + advance(400) + makePointerEvent("pointerdown", host, { + pointerType: "touch", + clientX: 0, + clientY: 0, + }) + advance(400) + // First timer (500ms from t=0) would have fired at t=500 — but it was + // cleared by the second pointerdown, and a fresh 500ms timer was armed. + expect(onContextMenu).not.toHaveBeenCalled() + advance(100) + expect(onContextMenu).toHaveBeenCalledTimes(1) + }) + + it("disabled hook never fires", () => { + const { host, onContextMenu } = renderHost({ enabled: false }) + makePointerEvent("pointerdown", host, { + pointerType: "touch", + clientX: 0, + clientY: 0, + }) + advance(1000) + expect(onContextMenu).not.toHaveBeenCalled() + }) + + it("mouse pointermove after a touch hold doesn't cancel — pointerType is filtered", () => { + const { host, onContextMenu } = renderHost() + makePointerEvent("pointerdown", host, { + pointerType: "touch", + clientX: 0, + clientY: 0, + }) + advance(200) + // A mouse move that happens to bubble through the same element must not + // cancel an in-flight touch gesture. + makePointerEvent("pointermove", host, { + pointerType: "mouse", + clientX: 1000, + clientY: 1000, + }) + advance(300) + expect(onContextMenu).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/hooks/use-long-press-to-open-menu.ts b/src/hooks/use-long-press-to-open-menu.ts new file mode 100644 index 000000000..0acc9155d --- /dev/null +++ b/src/hooks/use-long-press-to-open-menu.ts @@ -0,0 +1,124 @@ +"use client" + +import { useCallback, useEffect, useRef } from "react" +import type { PointerEvent as ReactPointerEvent } from "react" + +interface UseLongPressToOpenMenuOptions { + /** When false the hook ignores every gesture and never opens the menu. */ + enabled?: boolean + /** Hold duration before the synthetic contextmenu fires. */ + longPressMs?: number + /** + * Movement in either axis beyond this cancels the in-flight gesture. + * Mirrors the threshold used by `useLongPressDrag` so both gestures behave + * the same way when both hooks are attached to the same element. + */ + moveThresholdPx?: number +} + +/** + * Pointer handlers that open a Radix `ContextMenu` from a touch / pen + * long-press, while leaving desktop right-click to Radix's own contextmenu + * handler. + * + * Spread the four handlers onto a `` (or any + * element that Radix already listens on). The pointerdown handler composes + * with Radix's via `composeEventHandlers` — Radix's own 700ms long-press + * timer keeps running in parallel, but it clears on any `pointermove`, + * including the micro-moves a stationary touch can produce on mobile + * browsers. This hook tolerates movement below `moveThresholdPx` and only + * fires after the finger has been still for the full `longPressMs`. + * + * On fire, it dispatches a synthetic `MouseEvent("contextmenu", { button: 2, + * clientX, clientY })` from the current target. The synthetic event bubbles + * to Radix's `onContextMenu`, which opens the same menu the desktop right- + * click does — single source of truth, no duplication. Mouse pointers are + * ignored so desktop right-click keeps using Radix's native handler. + * + * NEVER spread this onto NESTED triggers (a tree row whose trigger encloses its + * descendants' triggers, say). One pointerdown bubbles through every ancestor, + * so each arms its own timer and each dispatches its own contextmenu from its + * OWN element — the ancestors' menus open right after the intended one and the + * outermost wins the screen. Radix's built-in long-press is safe there because + * all the triggers share ONE bubbling event and the innermost `preventDefault`s + * it; separate dispatches carry no such interlock. Use it on flat lists, or on + * a single trigger with no trigger ancestors. + */ +export function useLongPressToOpenMenu({ + enabled = true, + longPressMs = 500, + moveThresholdPx = 10, +}: UseLongPressToOpenMenuOptions = {}) { + const timerRef = useRef(null) + const startRef = useRef<{ x: number; y: number } | null>(null) + + const clear = useCallback(() => { + if (timerRef.current != null) { + window.clearTimeout(timerRef.current) + timerRef.current = null + } + startRef.current = null + }, []) + + useEffect( + () => () => { + clear() + }, + [clear] + ) + + const onPointerDown = useCallback( + (event: ReactPointerEvent) => { + if (!enabled) return + // Desktop right-click has its own contextmenu event — keep Radix's + // native handler in charge of opening the menu there. + if (event.pointerType === "mouse") return + clear() + // Capture the target now — `event.currentTarget` is nulled out by React + // after the handler returns, and we need it 500ms down the line. + const target = event.currentTarget + startRef.current = { x: event.clientX, y: event.clientY } + timerRef.current = window.setTimeout(() => { + timerRef.current = null + target.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + button: 2, + clientX: event.clientX, + clientY: event.clientY, + }) + ) + }, longPressMs) + }, + [enabled, longPressMs, clear] + ) + + const onPointerMove = useCallback( + (event: ReactPointerEvent) => { + if (!enabled) return + if (event.pointerType === "mouse") return + const start = startRef.current + if (!start) return + const dx = Math.abs(event.clientX - start.x) + const dy = Math.abs(event.clientY - start.y) + if (dx > moveThresholdPx || dy > moveThresholdPx) clear() + }, + [enabled, moveThresholdPx, clear] + ) + + const onPointerUp = useCallback(() => { + clear() + }, [clear]) + + const onPointerCancel = useCallback(() => { + clear() + }, [clear]) + + return { + onPointerDown, + onPointerMove, + onPointerUp, + onPointerCancel, + } +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index a785ae9aa..d596812f0 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2605,6 +2605,7 @@ "openInTerminal": "الطرفية", "openInCode": "VS Code", "linkedFolder": "مجلد مرتبط", + "moreActions": "المزيد من الإجراءات", "copyPath": "نسخ المسار", "upload": "رفع ملفات/مجلد", "download": "تنزيل ملف", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 0c990450b..7cc608930 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2605,6 +2605,7 @@ "openInTerminal": "Terminal", "openInCode": "VS Code", "linkedFolder": "Verknüpfter Ordner", + "moreActions": "Weitere Aktionen", "copyPath": "Pfad kopieren", "upload": "Dateien/Ordner hochladen", "download": "Datei herunterladen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 8e8143af4..6ed241707 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2605,6 +2605,7 @@ "openInTerminal": "Terminal", "openInCode": "VS Code", "linkedFolder": "Linked folder", + "moreActions": "More actions", "copyPath": "Copy path", "upload": "Upload files/folder", "download": "Download file", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 08f02a968..457db3b4f 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2605,6 +2605,7 @@ "openInTerminal": "Terminal", "openInCode": "VS Code", "linkedFolder": "Carpeta vinculada", + "moreActions": "Más acciones", "copyPath": "Copiar ruta", "upload": "Subir archivos/carpeta", "download": "Descargar archivo", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index b6f83dbbd..fd9c2f8a9 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2605,6 +2605,7 @@ "openInTerminal": "Terminal", "openInCode": "VS Code", "linkedFolder": "Dossier lié", + "moreActions": "Plus d'actions", "copyPath": "Copier le chemin", "upload": "Téléverser fichiers/dossier", "download": "Télécharger le fichier", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index f930c0d67..ddaca4ccd 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2605,6 +2605,7 @@ "openInTerminal": "ターミナル", "openInCode": "VS Code", "linkedFolder": "リンク済みフォルダー", + "moreActions": "その他のアクション", "copyPath": "パスをコピー", "upload": "ファイル/フォルダをアップロード", "download": "ファイルをダウンロード", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index b074cb958..5fd2cd814 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2605,6 +2605,7 @@ "openInTerminal": "터미널", "openInCode": "VS Code", "linkedFolder": "연결된 폴더", + "moreActions": "더 많은 작업", "copyPath": "경로 복사", "upload": "파일/폴더 업로드", "download": "파일 다운로드", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 7853c774f..7da8317f4 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2605,6 +2605,7 @@ "openInTerminal": "Terminal", "openInCode": "VS Code", "linkedFolder": "Pasta vinculada", + "moreActions": "Mais ações", "copyPath": "Copiar caminho", "upload": "Enviar arquivos/pasta", "download": "Baixar arquivo", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index c8c082e24..42a142673 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2605,6 +2605,7 @@ "openInTerminal": "终端", "openInCode": "VS Code", "linkedFolder": "关联的文件夹", + "moreActions": "更多操作", "copyPath": "复制路径", "upload": "上传文件/目录", "download": "下载文件", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 1c142ea97..1d76b2266 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2605,6 +2605,7 @@ "openInTerminal": "終端", "openInCode": "VS Code", "linkedFolder": "已連結的資料夾", + "moreActions": "更多操作", "copyPath": "複製路徑", "upload": "上傳檔案/目錄", "download": "下載檔案",