fix: open the Files ⋮ menus beside their button, above it when there is no room below, never over it

This commit is contained in:
Linus Rath
2026-11-04 13:36:13 +01:00
parent 49286649dd
commit a0165cdcd5
6 changed files with 131 additions and 24 deletions
@@ -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> = {}): 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<React.ComponentProps<typeof FileBrowser>> = {}, 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(
<FileBrowser
currentPath="/"
resources={resources}
isLoading={false}
error={null}
selectedResources={new Set()}
uploadProgress={null}
clipboard={null}
onNavigate={vi.fn()}
onToggleSelect={vi.fn()}
onCreateFolder={noop}
onUploadFolder={noop}
onCancelUpload={vi.fn()}
onDelete={noop}
onBatchDelete={noop}
onRename={noop}
onBatchDownload={noop}
onRefresh={noop}
onClearSelection={vi.fn()}
onCut={vi.fn()}
onCopy={vi.fn()}
onPaste={noop}
onMoveTo={noop}
onCreateFile={noop}
onDuplicate={noop}
getImageUrl={async () => ''}
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<HTMLElement>('[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();
});
});
+5 -4
View File
@@ -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")}
>
<Plus className="w-5 h-5" />
</Button>
-13
View File
@@ -230,16 +230,3 @@ export function MiddleCutName({ name, title, className }: { name: string; title?
</span>
);
}
/** 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;
}
+4 -4
View File
@@ -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)}
>
<MoreHorizontal className="w-4 h-4" />
</button>
@@ -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");
}}
>
<ArrowUpDown />
@@ -384,7 +384,7 @@ export function FilesHeader({
below="md"
menuItems={(close) => (
<>
{narrow && <ContextMenuItem icon={ArrowUpDown} label={t("sort_by")} onClick={() => { close(); if (toolsRef.current) sortMenu.openContextMenu(anchorEvent(toolsRef.current, "end"), null); }} />}
{narrow && <ContextMenuItem icon={ArrowUpDown} label={t("sort_by")} onClick={() => { close(); if (toolsRef.current) sortMenu.openContextMenuFrom(toolsRef.current, null, "end"); }} />}
{narrow && (viewMode === "list"
? <ContextMenuItem icon={LayoutGrid} label={t("grid_view")} onClick={() => { close(); onViewModeChange("grid"); }} />
: <ContextMenuItem icon={LayoutList} label={t("list_view")} onClick={() => { close(); onViewModeChange("list"); }} />)}
+2 -2
View File
@@ -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");
}}
>
<Plus />
+5 -1
View File
@@ -89,7 +89,11 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
}, []);
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<HTMLElement>("[data-menu-row]") ?? element;
setContextMenu({ isOpen: true, position, data });
}, []);
const closeContextMenu = useCallback(() => {