diff --git a/components/files/__tests__/ux-p2-pim-files-browser.test.tsx b/components/files/__tests__/ux-p2-pim-files-browser.test.tsx new file mode 100644 index 000000000..7659f8847 --- /dev/null +++ b/components/files/__tests__/ux-p2-pim-files-browser.test.tsx @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { FileBrowser } from '../file-browser'; +import { useFileStore, type FileResource } from '@/stores/file-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { DEFAULT_FILES_SETTINGS } from '@/lib/files-settings'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// Usability pass 2, Files (pim-5): the ⋮ of a row near the bottom opened its +// menu over itself, so a second click on the button hit "Delete". + +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/components/settings/share-collection-dialog', () => ({ ShareCollectionDialog: () => null })); +vi.mock('@/lib/standalone-display', () => ({ canOpenUrlInNewWindow: () => false })); + +const res = (id: string, name: string, extra: Partial = {}): FileResource => ({ + id, name, serverName: name, isDirectory: false, contentType: 'text/plain', contentLength: 10, + lastModified: '2026-10-01T00:00:00Z', blobId: `b-${id}`, parentId: null, ...extra, +} as FileResource); +const notes = res('n', 'notes.txt'); +const budget = res('x', 'budget.xlsx', { contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); +const docs = res('d', 'Documents', { isDirectory: true, contentType: '', blobId: null }); + +export function renderBrowser(props: Partial> = {}, resources: FileResource[] = [docs, notes, budget]) { + const client = { listAllFileNodesAcrossAccounts: vi.fn(async () => []) } as unknown as IJMAPClient; + act(() => { + useFileStore.setState({ client, sharedRoots: [], allFolders: [docs], currentParentId: null, pathStack: [{ id: null, name: '' }], hasListed: true }); + }); + const h = { + onPreviewFile: vi.fn(), + onPreviewImage: vi.fn(), + onDownload: vi.fn(async () => {}), + onShowFileInfo: vi.fn(), + onSetSelection: vi.fn(), + onUploadFiles: vi.fn(async () => {}), + }; + const noop = vi.fn(async () => {}); + render( + ''} + favorites={[]} + recentFiles={[]} + onToggleFavorite={vi.fn()} + onOpenRecent={vi.fn()} + onRenameNode={noop} + onDeleteNode={noop} + onDownloadNode={noop} + onRevealNode={noop} + onRemoveRecent={vi.fn()} + client={client} + ownAccountId="a" + {...h} + {...props} + />, + ); + return h; +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +beforeEach(() => { + localStorage.clear(); + act(() => { useSettingsStore.setState({ filesSettings: DEFAULT_FILES_SETTINGS }); }); +}); + +describe('the ⋮ of a row opens its menu beside the button, never over it (pim-5)', () => { + it('goes above a button near the bottom of the window', () => { + // 1280×720, as in the audit: the last row's ⋮ at y 536–568, a menu 357 px tall. + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280); + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(720); + const button = { left: 1200, right: 1232, top: 536, bottom: 568, width: 32, height: 32 }; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.getAttribute('role') === 'menu') return { x: 0, y: 0, left: 0, top: 0, right: 220, bottom: 357, width: 220, height: 357, toJSON: () => ({}) } as DOMRect; + if (this.hasAttribute('data-item-action')) return { x: button.left, y: button.top, ...button, toJSON: () => ({}) } as DOMRect; + return { x: 0, y: 0, left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0, toJSON: () => ({}) } as DOMRect; + }); + renderBrowser(); + const kebab = document.querySelector('[data-resource="budget.xlsx"] [data-item-action][title="context_menu"]')!; + expect(kebab).toBeTruthy(); + fireEvent.click(kebab, { detail: 1, clientX: 1216, clientY: 552 }); + const menu = screen.getByRole('menu'); + const top = parseFloat(menu.style.top); + // Above the button: the menu ends before the button starts. + expect(top + 357).toBeLessThanOrEqual(button.top); + // The row stays marked while its menu is open. + expect(document.querySelector('[data-resource="budget.xlsx"]')?.closest('[data-menu-opener]') ?? document.querySelector('[data-menu-opener]')).toBeTruthy(); + }); +}); diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index ffb6f7dce..6ce0ce0d2 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -28,7 +28,7 @@ import { FilesSidebar, type CreateKind } from "@/components/files/files-sidebar" import { FilesHeader, type Crumb, type SortDir, type SortKey, type ViewMode } from "@/components/files/files-header"; import { FileGridView, FileListView, type FileItemHandlers } from "@/components/files/file-views"; import { FileDetailsPanel } from "@/components/files/file-details-panel"; -import { INTERNAL_DRAG_TYPE, anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts"; +import { INTERNAL_DRAG_TYPE, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { AppMain } from "@/components/layout/app-landmarks"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; @@ -880,6 +880,7 @@ export function FileBrowser({ // ---- Context menu ---------------------------------------------------------- const openMenu = menu.openContextMenu; + const openMenuFrom = menu.openContextMenuFrom; const openItemMenu = useCallback((e: React.MouseEvent, resource: FileResource) => { // Acting on an item outside the selection makes it the selection, like a // desktop file manager. @@ -891,9 +892,9 @@ export function FileBrowser({ setKeyboardNav(false); const target = e.currentTarget as HTMLElement | null; // The "..." button opens the menu under itself; a right-click at the pointer. - if (e.type === "click" && target) openMenu(anchorEvent(target, "end"), { kind: "item", resource }); + if (e.type === "click" && target) openMenuFrom(target, { kind: "item", resource }, "end"); else openMenu(e, { kind: "item", resource }); - }, [selectedResources, onSetSelection, openMenu]); + }, [selectedResources, onSetSelection, openMenu, openMenuFrom]); const closeMenu = menu.closeContextMenu; const menuData = menu.contextMenu.data; @@ -1417,7 +1418,7 @@ export function FileBrowser({ size="icon-lg" className="absolute bottom-4 end-4 z-20 rounded-full shadow-lg" aria-label={t("new")} - onClick={(e) => menu.openContextMenu(anchorEvent(e.currentTarget, "end"), { kind: "new" })} + onClick={(e) => menu.openContextMenuFrom(e.currentTarget, { kind: "new" }, "end")} > diff --git a/components/files/file-item-parts.tsx b/components/files/file-item-parts.tsx index a2364ca85..6367cc432 100644 --- a/components/files/file-item-parts.tsx +++ b/components/files/file-item-parts.tsx @@ -230,16 +230,3 @@ export function MiddleCutName({ name, title, className }: { name: string; title? ); } - -/** Open a shared context menu as a dropdown under a button. */ -export function anchorEvent(el: HTMLElement, align: "start" | "end" = "start"): React.MouseEvent { - const rect = el.getBoundingClientRect(); - const rtl = typeof document !== "undefined" && document.dir === "rtl"; - const alignEnd = (align === "end") !== rtl; - return { - clientX: alignEnd ? rect.right - 200 : rect.left, - clientY: rect.bottom + 4, - preventDefault: () => {}, - stopPropagation: () => {}, - } as unknown as React.MouseEvent; -} diff --git a/components/files/files-header.tsx b/components/files/files-header.tsx index ba2dc457d..fd8a735e3 100644 --- a/components/files/files-header.tsx +++ b/components/files/files-header.tsx @@ -15,7 +15,7 @@ import { useContextMenu } from "@/hooks/use-context-menu"; import { useMediaQuery } from "@/hooks/use-media-query"; import { PhoneHeaderActions } from "@/components/layout/phone-header-actions"; import { cn } from "@/lib/utils"; -import { anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts"; +import { isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts"; import { PluginActions } from "@/components/plugins/plugin-actions"; import type { FileResourceView } from "@/lib/plugin-types"; @@ -303,7 +303,7 @@ export function FilesHeader({ className="px-1.5 py-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted" aria-label={t("show_path")} aria-haspopup="menu" - onClick={(e) => overflowMenu.openContextMenu(anchorEvent(e.currentTarget), hidden)} + onClick={(e) => overflowMenu.openContextMenuFrom(e.currentTarget, hidden)} > @@ -347,7 +347,7 @@ export function FilesHeader({ onMouseDown={(e) => { if (sortMenu.contextMenu.isOpen) e.stopPropagation(); }} onClick={(e) => { if (sortMenu.contextMenu.isOpen) sortMenu.closeContextMenu(); - else sortMenu.openContextMenu(anchorEvent(e.currentTarget, "end"), null); + else sortMenu.openContextMenuFrom(e.currentTarget, null, "end"); }} > @@ -384,7 +384,7 @@ export function FilesHeader({ below="md" menuItems={(close) => ( <> - {narrow && { close(); if (toolsRef.current) sortMenu.openContextMenu(anchorEvent(toolsRef.current, "end"), null); }} />} + {narrow && { close(); if (toolsRef.current) sortMenu.openContextMenuFrom(toolsRef.current, null, "end"); }} />} {narrow && (viewMode === "list" ? { close(); onViewModeChange("grid"); }} /> : { close(); onViewModeChange("list"); }} />)} diff --git a/components/files/files-sidebar.tsx b/components/files/files-sidebar.tsx index d47ea17a5..00bf414fe 100644 --- a/components/files/files-sidebar.tsx +++ b/components/files/files-sidebar.tsx @@ -13,7 +13,7 @@ import { useContextMenu } from "@/hooks/use-context-menu"; import { cn } from "@/lib/utils"; import { sharedFilePages, type FileResource } from "@/stores/file-store"; import { getFileIconByName } from "@/components/files/file-icons"; -import { anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts"; +import { isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts"; const COLLAPSED_KEY = "files-sidebar-collapsed"; const RECENT_LIMIT = 8; @@ -297,7 +297,7 @@ export function FilesSidebar({ onMouseDown={(e) => { if (newMenu.contextMenu.isOpen) e.stopPropagation(); }} onClick={(e) => { if (newMenu.contextMenu.isOpen) newMenu.closeContextMenu(); - else newMenu.openContextMenu(anchorEvent(e.currentTarget, "end"), null); + else newMenu.openContextMenuFrom(e.currentTarget, null, "end"); }} > diff --git a/hooks/use-context-menu.ts b/hooks/use-context-menu.ts index e10f08648..7796434be 100644 --- a/hooks/use-context-menu.ts +++ b/hooks/use-context-menu.ts @@ -89,7 +89,11 @@ export function useContextMenu(): UseContextMenuReturn { }, []); const openContextMenuFrom = useCallback((element: HTMLElement, data: T, align: "start" | "end" = "start") => { - setContextMenu({ isOpen: true, position: anchorPosition(element, align), data }); + const position = anchorPosition(element, align); + // Focus goes back to it, and the row it's in stays marked while the menu is open. + position.trigger = focusReturnTarget(element); + position.opener = element.closest("[data-menu-row]") ?? element; + setContextMenu({ isOpen: true, position, data }); }, []); const closeContextMenu = useCallback(() => {